mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 003741a63f | |||
| e644ab33cd | |||
| 9006ebbc67 | |||
| e40017f053 | |||
| fa7eea54d8 | |||
| 04d2360814 | |||
| 29aba936ce | |||
| 4bae88e107 | |||
| 3e4b97b11a | |||
| e555906553 | |||
| 4f1335242c | |||
| e683466bf0 | |||
| 788a3331d3 | |||
| 2d9b7bc099 | |||
| 717d37c693 | |||
| 1f48e9df47 | |||
| adab684ed0 | |||
| e23f9ab226 | |||
| 77203fe8fd | |||
| 5379778a20 | |||
| 9fc58dca4b | |||
| 17ac5c0a6f | |||
| 1ec2396c41 | |||
| 7c65fe80cb | |||
| a326087ca5 | |||
| de518ac205 | |||
| ce3cf0ef89 | |||
| 9626948f29 | |||
| dfa0a3d03e | |||
| 02c41776b8 | |||
| b32e2fa8cc | |||
| 9963da2ebc | |||
| 9feafab617 | |||
| d26b95985d | |||
| bb030a9737 | |||
| 95ff460856 | |||
| 96e2207cf3 | |||
| 2928393b4c | |||
| 5863991bfb | |||
| 1eca3fa5bb | |||
| 58af258fd3 | |||
| 69f8f4cfd7 | |||
| af9cf34f0b | |||
| b1ea280f0b | |||
| 9006570bd1 | |||
| f9dc9d4017 | |||
| 7b37d783b5 | |||
| 9f4be58172 | |||
| a968f708b3 | |||
| 2995afe970 | |||
| 68027a21d5 | |||
| 4aa6f6d7a6 | |||
| 7602bfdb21 | |||
| 3ccb967a45 | |||
| fe970de39a | |||
| e795339924 | |||
| 06b5ffbd0b | |||
| 24cac52674 | |||
| 6276ee134c | |||
| d847389eef | |||
| aa84b63c77 | |||
| bd60a14754 | |||
| a1bdcf4325 | |||
| b846803062 | |||
| 4bc7ad365d | |||
| 68b2205b0d | |||
| b450845522 | |||
| 27023ea58e | |||
| 1a7d23ce93 | |||
| d2c8ca1c1e | |||
| 1c404a7f11 | |||
| e2bba04efd | |||
| c7b29c277c | |||
| e06965b95c | |||
| 23d43741ce | |||
| 027d2dbde5 | |||
| a0c16df664 | |||
| 8fd866f20a | |||
| 8f72c238a2 | |||
| 24ad7c3732 | |||
| 32fd677a31 | |||
| fb22700268 | |||
| 59b675ce17 | |||
| 8a9293280b | |||
| 450f715874 | |||
| b4c00f36a0 | |||
| 36f494fa55 | |||
| b5b3885d19 | |||
| 373f2d127a | |||
| 3ebe80aab2 | |||
| e7b59d61ac | |||
| 71f7ac425a | |||
| 394b9ba474 | |||
| 526736b045 | |||
| 86112057b4 | |||
| c56fcfd281 | |||
| 5c43fc6236 | |||
| 101761e984 | |||
| 3bc68a5713 | |||
| 6c96dd6344 | |||
| d7bb8dc97c | |||
| d38a840114 | |||
| e7d2d76ba3 | |||
| 5277cd0f21 | |||
| 4d7cbfe347 | |||
| 91aa76e842 | |||
| 5f0a189451 | |||
| 312d4f2cf7 | |||
| 884feb982d | |||
| b2da17ab62 | |||
| b518685e2c | |||
| b7a7a14a35 | |||
| 9ee22d985f | |||
| 2070e701de | |||
| dd8eefe3f9 | |||
| cc97e0d371 | |||
| 7571f1986d | |||
| bb1a88c085 | |||
| 07863bdad6 | |||
| 5e6306432f | |||
| fa5dfc1cf1 | |||
| b88b8bbe47 | |||
| 8aab8ce2f5 | |||
| 95b4608236 | |||
| 6d42ee7d4c | |||
| 3f8cf33dc9 | |||
| f12bb0a532 | |||
| 1685b6a327 | |||
| ec8f56a85d | |||
| 38dc923a7c | |||
| 4ca1ef22d2 | |||
| 27e57862c0 | |||
| c391068706 | |||
| d76e9195c4 | |||
| 6652a06840 | |||
| b1a89cff41 | |||
| e4a77a3ef4 | |||
| 5adcce2074 | |||
| a1398ed0e4 |
@@ -37,7 +37,7 @@ Closes #
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have added/updated tests if needed
|
||||
- [ ] I have updated documentation if needed (`docs/`, README, etc.)
|
||||
- [ ] Protocol changes: I ran `node scripts/build-extension.js` and updated relevant docs
|
||||
- [ ] Protocol changes: I ran `node scripts/build-extension.cjs` and updated relevant docs
|
||||
- [ ] No new warnings, secrets, or hardcoded credentials introduced
|
||||
|
||||
### Additional Context
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Beta Server Image
|
||||
|
||||
# Publishes the relay server as a Docker image under NON-production tags so a
|
||||
# feature branch can be deployed to a staging/backup server and used as a custom
|
||||
# server, without ever touching the ':latest' tag the official relay tracks.
|
||||
#
|
||||
# Tags produced (on ghcr.io/<owner>/<repo>):
|
||||
# - beta moving channel pointer to the newest build
|
||||
# - <branch-slug> e.g. feature-host-control-mode
|
||||
# - sha-<short-commit> immutable, pin to an exact build
|
||||
# - <custom> only on manual run, e.g. hostcontrol01
|
||||
# Never ':latest' (flavor: latest=false).
|
||||
#
|
||||
# Remove this workflow once the feature is merged & released the normal way.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature/host-control-mode
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Extra immutable tag for this build (e.g. hostcontrol01). Optional.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
concurrency:
|
||||
group: beta-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-beta-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
# Never publish ':latest' from a beta build.
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=beta
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: server/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,46 @@
|
||||
name: CI
|
||||
|
||||
# Runs the full verification suite (lint, unit/integration tests, production
|
||||
# audits, extension + website build) on every push to main and every PR, so a
|
||||
# regression can never reach main or a release tag unchecked.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel superseded runs on the same ref to save CI minutes. Unlike the release
|
||||
# workflow, an interrupted CI run has no side effects.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
server/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
# The server test suite (test-server-ws/routes/ops) imports express,
|
||||
# socket.io, and dotenv from server/node_modules, so install them too.
|
||||
- name: Install server dependencies
|
||||
run: npm ci
|
||||
working-directory: server
|
||||
|
||||
- name: Run verification suite
|
||||
run: npm run verify
|
||||
@@ -5,23 +5,29 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# A release run must never be interrupted (it commits back to main and publishes
|
||||
# artifacts). Only dedupe accidental re-pushes of the same tag.
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-server:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -29,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -38,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: server/Dockerfile
|
||||
@@ -46,6 +52,9 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Reuse layers across releases to speed up the multi-arch build.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest@v4
|
||||
@@ -62,7 +71,13 @@ jobs:
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
@@ -79,7 +94,7 @@ jobs:
|
||||
echo " ✓ manifest.base.json -> $VERSION"
|
||||
|
||||
# 2. shared/constants.js — APP_VERSION
|
||||
sed -i "s/export const APP_VERSION = '.*'/export const APP_VERSION = '$VERSION'/" shared/constants.js
|
||||
sed -i "s/export const APP_VERSION = [\"'].*[\"']/export const APP_VERSION = \"$VERSION\"/" shared/constants.js
|
||||
echo " ✓ shared/constants.js -> $VERSION"
|
||||
|
||||
# 3. package.json
|
||||
@@ -94,17 +109,22 @@ jobs:
|
||||
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
|
||||
echo " ✓ website/template.html -> softwareVersion $VERSION"
|
||||
|
||||
# 6. README.md — version badge
|
||||
sed -i "s/v[0-9]\+\.[0-9]\+\.[0-9]\+/v$VERSION/g" README.md
|
||||
# 6. README.md — version badge & banner
|
||||
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
|
||||
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
|
||||
echo " ✓ README.md -> v$VERSION"
|
||||
|
||||
# 7. website/sitemap.xml — lastmod dates
|
||||
sed -i "s/<lastmod>[0-9-]*<\/lastmod>/<lastmod>$(date +%Y-%m-%d)<\/lastmod>/g" website/sitemap.xml
|
||||
echo " ✓ website/sitemap.xml -> lastmod $(date +%Y-%m-%d)"
|
||||
|
||||
echo "Version injection complete."
|
||||
|
||||
- name: Commit and push version updates back to main
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md website/sitemap.xml
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
@@ -112,7 +132,7 @@ jobs:
|
||||
|
||||
- name: Build Extensions
|
||||
run: |
|
||||
npm install
|
||||
npm ci
|
||||
npm run build:extension
|
||||
|
||||
- name: Generate artifact attestation for extensions
|
||||
@@ -121,17 +141,17 @@ jobs:
|
||||
subject-path: dist/koalasync-*.zip
|
||||
|
||||
- name: Build Website
|
||||
run: node website/build.js
|
||||
run: node website/build.cjs
|
||||
|
||||
- name: Upload Website Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: website-www
|
||||
path: website/www/
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
dist/koalasync-chrome.zip
|
||||
|
||||
@@ -27,6 +27,7 @@ Thumbs.db
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
|
||||
+30
-18
@@ -12,7 +12,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|
||||
|------|-------------|
|
||||
| **Bug Reports** | Found a bug? Open an issue with repro steps (see template below). |
|
||||
| **Code** | Fix bugs, add features, or improve the extension / server / website. |
|
||||
| **Translations** | Help localize the extension and website into more languages. See [TRANSLATION.md](website/TRANSLATION.md). |
|
||||
| **Translations** | Help localize the extension and website into more languages. See [TRANSLATION.md](docs/TRANSLATION.md). |
|
||||
| **Documentation** | Improve docs, fix typos, or add missing examples. |
|
||||
| **Security** | Found a vulnerability? See [SECURITY.md](SECURITY.md) — do NOT open a public issue. |
|
||||
|
||||
@@ -31,7 +31,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|
||||
git clone https://github.com/Shik3i/KoalaSync.git
|
||||
cd KoalaSync
|
||||
npm install
|
||||
node scripts/build-extension.js
|
||||
node scripts/build-extension.cjs
|
||||
```
|
||||
|
||||
---
|
||||
@@ -62,7 +62,7 @@ node scripts/build-extension.js
|
||||
### Website
|
||||
|
||||
```bash
|
||||
node website/build.js # Compile static site → www/
|
||||
node website/build.cjs # Compile static site → www/
|
||||
python3 -m http.server 8080 -d website/www # Serve locally
|
||||
```
|
||||
|
||||
@@ -77,7 +77,7 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
|
||||
> [!IMPORTANT]
|
||||
> After modifying `shared/constants.js`, you **must** run the build script to sync changes to the extension:
|
||||
> ```bash
|
||||
> node scripts/build-extension.js
|
||||
> node scripts/build-extension.cjs
|
||||
> ```
|
||||
> This automatically injects constants into `content.js` and regenerates browser bundles in `dist/`.
|
||||
|
||||
@@ -100,15 +100,23 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
## Open Source Workflow & Pull Requests
|
||||
|
||||
1. **Branch** from `main` for your feature or fix.
|
||||
2. **Test locally** on both Chrome and Firefox.
|
||||
3. **Website changes**: Run `node website/build.js` and verify the compiled output in `www/`.
|
||||
4. **Lint**: Ensure `npm run lint` passes with zero errors and zero warnings.
|
||||
5. **Syntax**: Run `node -c` on every modified `.js` file.
|
||||
6. **Protocol changes**: Update relevant documentation in `docs/`.
|
||||
7. **Submit your PR** with a clear description and linked issue (if applicable).
|
||||
If you are new to open-source contributions, follow these steps to propose your changes:
|
||||
|
||||
1. **Fork the Repository**: Click the "Fork" button at the top right of this repository to create your own copy of KoalaSync.
|
||||
2. **Clone your Fork**: `git clone https://github.com/YOUR-USERNAME/KoalaSync.git`
|
||||
3. **Create a Branch**: `git checkout -b my-new-feature` (e.g. `feature/dark-mode` or `fix/translation-de`)
|
||||
4. **Make your Changes**: Edit the files, then verify them locally.
|
||||
- *Extension/Server changes*: Test on Chrome/Firefox and check `npm run lint`.
|
||||
- *Website/Translation changes*: Run `node website/build.cjs` and check the output in `www/`.
|
||||
5. **Commit and Push**: `git commit -m "Add my feature"` and `git push origin my-new-feature`
|
||||
6. **Open a Pull Request (PR)**: Go to the original KoalaSync repository on GitHub and click "New Pull Request".
|
||||
|
||||
### PR Code Requirements
|
||||
- **Lint**: Ensure `npm run lint` passes with zero errors and warnings.
|
||||
- **Syntax**: Run `node -c` on every modified `.js` file.
|
||||
- **Protocol changes**: Update relevant documentation in `docs/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -140,14 +148,18 @@ If you cannot access the Status tab, include as much of the following manually:
|
||||
|
||||
---
|
||||
|
||||
## Translation Contributions
|
||||
## Translation Contributions (Translators Welcome!)
|
||||
|
||||
KoalaSync supports 6 languages: English, German, French, Spanish, Portuguese (Brazilian), and Russian.
|
||||
We welcome native speakers to help translate KoalaSync! You **do not** need deep programming knowledge to contribute translations.
|
||||
|
||||
To add or improve translations:
|
||||
1. Edit the locale files in `website/locales/` (for the website).
|
||||
2. For extension translations, see [TRANSLATION.md](website/TRANSLATION.md).
|
||||
3. Run `node website/build.js` to regenerate the static site.
|
||||
KoalaSync supports multiple languages. To add or improve translations:
|
||||
1. Read the **[Translation Guide](docs/TRANSLATION.md)** first. It explains how our localization system works.
|
||||
2. Edit the `.json` files in `website/locales/` (for the website) and `extension/locales/` (for the extension).
|
||||
3. Test your translations locally by running:
|
||||
- `node scripts/test-locales.cjs` (for extension)
|
||||
- `node scripts/test-website-locales.mjs` (for website)
|
||||
- `node website/build.cjs` (to build the site)
|
||||
4. Follow the **Open Source Workflow** above (Fork -> Branch -> Edit -> PR) to submit your translations.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/github/v/release/Shik3i/KoalaSync" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Shik3i/KoalaSync?color=blue" alt="License"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.5.2-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="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>
|
||||
</p>
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.2.4 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.5.2 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
@@ -59,9 +59,9 @@ 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:
|
||||
- **Available Languages**: Support is included for 13 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`), and European Portuguese (`pt`).
|
||||
- **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](website/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
|
||||
- **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.
|
||||
|
||||
|
||||
---
|
||||
@@ -81,7 +81,7 @@ Both the official KoalaSync website and the **v2.0 Browser Extension** feature f
|
||||
To build the extension from source and synchronize protocol constants:
|
||||
```bash
|
||||
npm install
|
||||
node scripts/build-extension.js
|
||||
npm run build:extension
|
||||
```
|
||||
The compiled artifacts will be available in the `dist/` directory.
|
||||
|
||||
@@ -103,7 +103,7 @@ To connect your extension to a self-hosted server, open the popup → **Room** t
|
||||
|
||||
To verify your relay is reachable from outside, visit `https://your-domain.com` in a browser — it should return `{"status":"online","service":"KoalaSync Relay"}`.
|
||||
|
||||
#### Supply Chain Security (v2.2.4+)
|
||||
#### Supply Chain Security (v2.2.2+)
|
||||
|
||||
All official release artifacts (Docker images and extension binaries) are published with signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) to prove they were built from this repository's source code.
|
||||
|
||||
@@ -125,12 +125,13 @@ gh attestation verify dist/koalasync-chrome.zip \
|
||||
|
||||
- **[CHANGELOG.md](docs/CHANGELOG.md)**: Full version history for the extension and relay server.
|
||||
- **[TESTED_SERVICES.md](docs/TESTED_SERVICES.md)**: Detailed compatibility matrix of tested streaming platforms and known limitations.
|
||||
- **[TRANSLATION.md](website/TRANSLATION.md)**: Translation and localization guide for contributors.
|
||||
- **[TRANSLATION.md](docs/TRANSLATION.md)**: Translation and localization guide for contributors.
|
||||
- **[PRIVACY.md](docs/PRIVACY.md)**: Data Handling and Privacy Policy.
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: How to help make KoalaSync better.
|
||||
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
|
||||
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
|
||||
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
|
||||
- **[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.
|
||||
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
|
||||
@@ -138,7 +139,7 @@ gh attestation verify dist/koalasync-chrome.zip \
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub><a href="https://ko-fi.com/koaladev"><img src="https://img.shields.io/badge/Ko--Fi-Support-FF5E5B?logo=ko-fi&logoColor=white" alt="Ko-Fi"></a></sub>
|
||||
<sub><a href="https://support.koalastuff.net"><img src="https://img.shields.io/badge/Support-KoalaSync-FF5E5B" alt="Support KoalaSync"></a></sub>
|
||||
<sub><a href="https://gitgem.org/github/Shik3i/KoalaSync"><img src="https://gitgem.org/api/badge/github/Shik3i/KoalaSync.svg" alt="GitGem Badge" /></a></sub>
|
||||
|
||||
<sub>Built with ❤️ by <a href="https://github.com/Shik3i">Shik3i</a>. KoalaSync is Open Source under the <a href="LICENSE">MIT License</a>.</sub>
|
||||
|
||||
@@ -82,6 +82,12 @@ KoalaSync's security is grounded in its architecture:
|
||||
|
||||
If you find a way to bypass any of these protections, we want to know about it.
|
||||
|
||||
> [!NOTE]
|
||||
> Some frequently-reported "issues" are **intentional and out of scope** for our
|
||||
> threat model (ephemeral, account-less rooms of invited participants) — e.g. an
|
||||
> unauthenticated `peerId` or non-constant-time room-password compare. Before
|
||||
> reporting, please read **[`docs/KNOWN_LIMITATIONS.md`](docs/KNOWN_LIMITATIONS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Responsible Disclosure
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# KoalaSync — Marketing Copy Kit
|
||||
|
||||
Ready-to-paste copy for product listings, launch pages, directory submissions, and anywhere else you keep re-typing the same pitch. Three lengths, one consistent message. Pick the one that fits the field limit.
|
||||
|
||||
---
|
||||
|
||||
## 1. One-Sentence Pitch
|
||||
|
||||
> KoalaSync is a privacy-first browser extension that synchronizes video playback across almost any website so you can watch with friends in real time — no accounts, no tracking, and your video never passes through anyone's server but the original site's.
|
||||
|
||||
**Shorter alternative** (for tight tagline fields):
|
||||
|
||||
> Private, universal watch parties on any website — no accounts, no tracking, no media proxying.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Full Description
|
||||
|
||||
### Watch together — on any site, on your terms.
|
||||
|
||||
Counting down "3, 2, 1, play" over voice chat doesn't scale past two people. KoalaSync fixes that with a tiny browser extension that synchronizes play, pause, and seeking across everyone in the room, on almost any website with a `<video>` element. Create a room, share a link, press play — that's it.
|
||||
|
||||
### What makes KoalaSync different
|
||||
|
||||
Most watch-party tools fall into one of two traps: they only work on a short allowlist of sites (site-specific extensions that need a separate build for every platform), or they route your video through their own player and servers. KoalaSync was built around three principles that break that mold.
|
||||
|
||||
**Universal by design.** If the site has an HTML5 `<video>` element, KoalaSync can usually sync it. YouTube, Netflix, Twitch, Prime Video, Disney+, Jellyfin, Emby, and countless niche sites work out of the box — no per-site integration to wait for, no extension swap when your friends want to switch services.
|
||||
|
||||
**Your video never touches our servers.** KoalaSync only relays tiny timing messages — play, pause, seek position, readiness — over a hand-rolled WebSocket protocol. The actual video keeps streaming directly from the original site to each viewer's browser. KoalaSync never proxies, transcodes, uploads, or redistributes a single frame, which also means there is no legal gray zone around redistribution.
|
||||
|
||||
**Privacy is the default, not an upgrade.** No accounts, no emails, no telemetry, no analytics, no behavior profiling. The official relay server runs entirely in volatile RAM and keeps zero persistent state — when the room closes, the data is gone. Pick a nickname or let KoalaSync generate one for you and you're in.
|
||||
|
||||
### 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.
|
||||
|
||||
### Little touches you'll notice
|
||||
|
||||
- **Episode Auto-Sync** pauses the room when someone loads the next episode and resumes only when everyone is ready — no spoilers, no one left behind on the previous cliffhanger.
|
||||
- **Smart Audio Compressor** tames the modern "whisper dialogue, deafening explosion" mix with one click. Three presets or full manual control over threshold, ratio, attack, and release.
|
||||
- **One-click invite links** auto-configure the server and room for your friends — they just click the link and they're in. No fumbling with server URLs or room IDs.
|
||||
- **Dual-heartbeat architecture** kills ghost rooms and stale connections before they desync your session.
|
||||
- **15 languages** fully translated and switchable in real time from the settings panel — English, German, French, Spanish, Portuguese (Brazil + European), Russian, Italian, Polish, Turkish, Dutch, Japanese, Korean, Chinese, Ukrainian.
|
||||
|
||||
### Install and start in under a minute
|
||||
|
||||
Install KoalaSync from the Chrome Web Store or Firefox Add-ons, click "Create Room," share the invite link, and pick a video. The official relay is ready out of the box — no setup required unless you want to self-host.
|
||||
|
||||
- Website: https://sync.koalastuff.net
|
||||
- GitHub: https://github.com/Shik3i/KoalaSync
|
||||
|
||||
---
|
||||
|
||||
## Bonus: Taglines (for hero headlines, social bios, meta descriptions)
|
||||
|
||||
- Watch together. Anywhere. Privately.
|
||||
- The watch-party tool that works on every site and tracks none of them.
|
||||
- Sync play, pause, and seek on any video — no accounts, no logs, no lock-in.
|
||||
- Self-hostable, open-source watch parties for the post-"3, 2, 1, play" era.
|
||||
- Press play once. Stay together anywhere.
|
||||
@@ -40,7 +40,7 @@ KoalaSync is built for private watch parties without unnecessary data collection
|
||||
⚙️ UNDER THE HOOD
|
||||
KoalaSync is lightweight, transparent, and built with privacy in mind.
|
||||
|
||||
• Real-Time Relay: Playback state is synchronized through a custom WebSocket-based relay server for fast room updates.
|
||||
• 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.
|
||||
• 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.
|
||||
|
||||
+8
-8
@@ -21,14 +21,14 @@ 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.js`**: Zero-dependency static site compiler. Translates `template.html` + `locales/*.json` → `www/`. Also minifies CSS/JS automatically.
|
||||
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.js` to regenerate. CSS/JS are output as `.min.*` files — a built-in cleanup step removes stale artifacts on each build.
|
||||
- **`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.
|
||||
- `scripts/`: Development utilities (e.g., `build-extension.js`).
|
||||
- `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.js`.
|
||||
> **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`.
|
||||
> - **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.
|
||||
|
||||
@@ -41,7 +41,7 @@ Before touching any code, you MUST read the following documents in order:
|
||||
## 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.js`) automatically injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` using marker-based replacement (see `../scripts/README.md` for marker details).
|
||||
- **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.
|
||||
|
||||
## 5. File Responsibility Map
|
||||
@@ -137,18 +137,18 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
|
||||
|
||||
### Adding a Protocol Event
|
||||
1. Add the event name to `shared/constants.js`.
|
||||
2. Run the build script (`node scripts/build-extension.js`).
|
||||
2. Run the build script (`node scripts/build-extension.cjs`).
|
||||
3. Implement the handler in `server/index.js` and `background.js`.
|
||||
|
||||
### Making Website Changes
|
||||
1. Edit source files in `website/` (`template.html`, `style.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
|
||||
2. Run the compiler: `node website/build.js`. This generates the multilingual pages in `www/` and minifies CSS/JS.
|
||||
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`.
|
||||
4. Test locally: `npx serve website/www` or `python3 -m http.server 8080 -d website/www`.
|
||||
5. Commit both source changes and the updated `www/` output.
|
||||
|
||||
### Testing Locally
|
||||
1. Run the build script: `node scripts/build-extension.js`.
|
||||
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`.
|
||||
4. Use **different browser profiles** or vendors to test multi-peer logic.
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
This document describes the communication flows and internal logic of the KoalaSync system.
|
||||
|
||||
## 1. Extension Startup & Connection
|
||||
- **Initialization**: On startup, `background.js` reads settings (Server URL, Username, Last Room) from `chrome.storage.sync`.
|
||||
- **WebSocket Handshake**:
|
||||
## 1. Extension Connection (Lazy Connect)
|
||||
- **Initialization**: On startup, `background.js` reads settings (Server URL, Username, Last Room) from `chrome.storage.sync`. No WebSocket connection is established at this point.
|
||||
- **On-Demand Connection**: The extension only connects when needed — either the user opens the popup with saved room credentials, or when actively in a room. When not in a room, no connection exists. This improves privacy (IP not exposed while idle) and reduces battery/network usage.
|
||||
- **WebSocket Handshake (when connecting)**:
|
||||
1. Background creates a `new WebSocket` to `/socket.io/?EIO=4&transport=websocket&version=1.0.0`.
|
||||
2. Server performs security checks:
|
||||
- **IP Rate Limit**: Checks if the IP has exceeded connection limits.
|
||||
@@ -45,7 +46,7 @@ To maintain a clean room state and eliminate "Ghost Peers":
|
||||
- **Video Heartbeat (Content)**: Every 15 seconds, `content.js` sends current playback metadata (time, title, state) if a video is found.
|
||||
- **Server Pruning**: The server runs a "Reaper" every 2 minutes. If a peer has sent **zero** activity (no events and no heartbeats) for 5 minutes, they are forcefully disconnected.
|
||||
- **Immediate Cleanup**: Rooms are deleted instantly when the last peer leaves or disconnects.
|
||||
- **Reconnect Strategy**: Aggressive backoff — 500ms base, 1.5x multiplier, capped at 5s. Max 20 attempts before marking as failed. Events are queued during disconnect and flushed after namespace rejoin.
|
||||
- **Reconnect Strategy (while in room)**: Aggressive backoff — 500ms base, 1.5x multiplier, capped at 5s. Max 20 attempts before marking as failed. Events are queued during disconnect and flushed after namespace rejoin. When not in a room, no reconnection occurs.
|
||||
|
||||
> [!CAUTION]
|
||||
> **Identity Rule**: Differentiate between `peerId` and `socket.id`. Use `socket.id` exclusively for ephemeral transport routing on the server. Use `peerId` exclusively for identity, state management, and room tracking across the stack.
|
||||
@@ -70,5 +71,5 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
|
||||
To maintain a "Single Source of Truth" across the server and extension without using a bundler:
|
||||
- **Relay Server & Extension Modules**: `background.js` and `popup.js` import constants directly from `shared/constants.js`.
|
||||
- **Content Scripts**: To ensure zero-latency execution, `content.js` uses a synchronized copy of `EVENTS` and constants.
|
||||
- **Automation**: The `node scripts/build-extension.js` script automatically injects these constants into `content.js` during the build process, eliminating the risk of manual mirror mismatch.
|
||||
- **Automation**: The `npm run build:extension` script automatically injects `EVENTS`, `HEARTBEAT_INTERVAL`, and `episode-utils.js` functions into `content.js` during the build process, eliminating the risk of manual mirror mismatch.
|
||||
- **Verification**: Any protocol change is automatically propagated across the stack by running the build script.
|
||||
|
||||
+147
-2
@@ -4,7 +4,152 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.3.0] — Unreleased
|
||||
## Unreleased
|
||||
|
||||
---
|
||||
|
||||
## [v2.5.3] — 2026-07-02
|
||||
|
||||
### Fixed
|
||||
- **Extension: Disney+ force sync and seek reliability** — The v2.5.2 Disney+ page-API integration leaked blob-relative `<video>` time into force sync, seeks, and heartbeats when the page-API bridge had no fresh data, which presented as "force sync does nothing on Disney+". Time/duration accessors now refuse to return native values on Disney+ (returning null/0) so stale bridge data degrades to a clean no-op instead of broadcasting garbage. `FORCE_SYNC_PREPARE` and `SEEK` payloads are now validated as finite before being relayed, and the popup's force-sync flow fails cleanly with a clear error rather than sending NaN through.
|
||||
- **Extension: Force-sync no longer routed twice** — A popup-initiated `FORCE_SYNC_PREPARE` was being delivered to the content script twice (once by the generic popup route and once by the force-sync-specific route), causing a double seek. The generic route is now scoped to play/pause/seek only.
|
||||
- **Extension: Disney+ Host Control Mode classification** — `hcmIsLive` previously read the native `<video>` duration to detect live streams, which on Disney+ is blob-relative garbage and falsely classified every stream as live (disabling snap-back and the desync dialog). The native-duration live signal is now skipped on Disney+ while YouTube/Twitch live detection via `Infinity` duration is preserved.
|
||||
- **Extension: Disney+ episode auto-sync and lobby readiness** — Episode-transition detection and the episode-lobby "ready" poll now read the playhead through the gated time accessor, so they no longer rely on blob-relative `currentTime` on Disney+.
|
||||
- **Extension: Force-sync median no longer skewed by peers without a known time** — A peer broadcasting `currentTime: null` (e.g. a Disney+ peer whose page-API bridge was not yet ready, or a freshly joined peer) was coerced to `0` by the jump-to-others median calculation, dragging the sync target toward the start of the video. Null and empty peer times are now excluded before the median.
|
||||
- **Extension: Force-sync `jump-to-me` retry on Disney+** — When the content script responds but the page-API bridge has not yet delivered a finite time (typical during the first ~250 ms after a Disney+ player loads), the popup now retries once without redundantly re-injecting the content script.
|
||||
- **Extension: Empty-string seek payload rejection** — The internal seek-time coercion no longer treats `''` as `0`; an empty-string `targetTime`/`currentTime` is rejected as invalid.
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
---
|
||||
|
||||
## [v2.5.0] — 2026-06-29
|
||||
|
||||
### Added
|
||||
- **Extension + Relay: Host Control Mode** — Room owners can now switch a room between open playback control and host-controlled playback. In host-only mode, guests stay synchronized but their local play, pause, and seek actions are not rebroadcast to the room.
|
||||
- **Backward-compatible Host Control rollout** — The extension only shows Host Control when the connected relay supports it, so users on older self-hosted servers do not see controls that cannot work yet.
|
||||
- **Extension: Clear host and guest states** — The popup shows the current control mode, host status, peer roles, and localized guest guidance so participants understand when playback is controlled by the host.
|
||||
- **Website: FAQ clarification for streaming access** — The landing page and FAQ structured data now state clearly that KoalaSync does not stream, host, share, or bypass access to video content. Every participant watches locally and needs their own access to services such as Netflix.
|
||||
|
||||
### Changed
|
||||
- **Playback sync now follows the room's control setting** — When Host Control is enabled, only the host can drive room-wide playback changes; guests can still watch in sync without accidentally changing playback for everyone.
|
||||
|
||||
---
|
||||
|
||||
## [v2.4.6] — 2026-06-23
|
||||
|
||||
### Fixed
|
||||
- **Room and settings are no longer stored in `chrome.storage.sync`** — Room ID, password, and username were being resurrected from synced storage on a fresh install (sync survives an uninstall in the user's Google account), which made the extension silently auto-connect to a dead room and appear permanently connected. `getSettings()` and all settings reads are now local-only, and legacy keys are actively purged from sync on install/update/startup. Only `onboardingComplete` and `dismissedHints` remain in sync.
|
||||
- **No server traffic while alone in a room** — When you are the only peer, heartbeats, force-sync, and episode auto-sync are now fully suppressed (previously the keepAlive heartbeat, force-sync, and episode lobby were still broadcast to an empty room). The solo state is re-evaluated live on every event — never cached — so the instant another peer joins, syncing resumes immediately, including an instant state push so the newcomer sees your current position without waiting for the next heartbeat.
|
||||
|
||||
## [v2.4.4] — 2026-06-23
|
||||
|
||||
### Changed
|
||||
- **Server: Event rate limit raised 30 → 50 per 10s**, and all connection/event/health rate-limit thresholds and windows extracted into named constants.
|
||||
- **Extension: Reconnect backoff tuned and jittered** — capped at ~8 attempts/60s (under the per-IP connection limit) with ±20% jitter to de-synchronize reconnect herds after a server blip.
|
||||
- **CI: Added a verification workflow** running lint, tests, audits, and builds on every push/PR; the release build now uses `npm ci`.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Offline event-queue flush is now paced** (small batches instead of one synchronous burst) so a reconnect after a long outage no longer trips the server event limit and gets disconnected on rejoin.
|
||||
- **Extension: Ping liveness tolerates one missed PONG** — a reconnect is forced only after 2 consecutive misses (~20s) instead of a single 5s timeout, avoiding spurious drops under transient load.
|
||||
- **Extension: `socket.send()` failures are caught and re-queued** instead of losing the event on a disconnect race.
|
||||
|
||||
## [v2.4.3] — 2026-06-19
|
||||
|
||||
### Added
|
||||
- **Two new languages: Ukrainian (`uk`) and Chinese (`zh`, Simplified)** — added across the extension (UI strings + Chrome `_locales`) and the website (localized pages, hreflang/Open Graph/schema tags, language selector), bringing the total to 15 languages.
|
||||
|
||||
### Changed
|
||||
- **Play/pause sync coalescing** — The content script now collapses rapid bursts of native play/pause events (source swaps, ABR/quality switches, ad transitions, page teardown) into a single relayed command: the first event is sent instantly and a short 150ms window absorbs the rest. This cuts redundant relay traffic and stops bursts from tripping the server's per-socket event rate limit.
|
||||
|
||||
### Fixed
|
||||
- **zh/uk translation quality** — Corrected systematic machine-translation word-sense errors in the two new locales (e.g. "Play", "Status", "Leave Room", "Clear", "Open", "peers", and audio compressor terms) and translated the remaining English leftovers.
|
||||
- **Relay logging** — An `EVENT_ACK` aimed at a peer that already left is now logged quietly instead of as a `[SECURITY]` cross-room event, so genuine cross-room attempts stand out in the logs.
|
||||
|
||||
## [v2.4.2] — 2026-06-19
|
||||
|
||||
### Changed
|
||||
- **Extension: Optimized uninstall URL registration** — Extracted registration into a reusable, race-condition-protected `initUninstallURL()` helper. It registers the uninstall feedback URL with browser context on both extension installation/update and browser startup to prevent state loss, without storing or sending an installation token.
|
||||
|
||||
## [v2.4.1] — 2026-06-19
|
||||
|
||||
### Added
|
||||
- **Extension: Onboarding tour now has a closing step** — The first-run tour ends on a dedicated "You're all set!" card (the `ONBOARDING_5` copy that already existed in all 13 locales but was never shown). The tour no longer stops abruptly on the username step.
|
||||
- **Extension: One-click invite from the empty peer list** — The "No peers yet" state now shows a **📋 Invite Link** button that copies the invite link to the clipboard, so users can share it without hunting for the field.
|
||||
|
||||
### Changed
|
||||
- **Extension: Cleaner onboarding welcome** — Step 1 is now a centered welcome card instead of spotlighting the logo title. Added a guard so target-less tour steps center cleanly.
|
||||
- **Website: Mobile comparison table** — The KoalaSync vs Teleparty table stacks into per-feature cards on phones instead of forcing horizontal scrolling; feature descriptions are shown again on mobile.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Onboarding step counter/progress placeholders** — Static `Step 1 of 3` / 33% fallbacks in `popup.html` corrected to match the actual 5-step tour (`Step 1 of 5` / 20%).
|
||||
- **Website: Mobile navigation restored** — The header hamburger menu was hidden by a `display:none !important` rule, leaving the nav links unreachable on phones. Re-enabled, with spacing kept comfortable down to ~320px.
|
||||
- **Website: Hero alignment on mobile** — A fixed-width extension mockup forced the hero grid column wider than the container, shifting all hero content off-center (larger left margin than right). The mockup is now responsive (`width:100%/max-width` + `minmax(0,1fr)` grid track).
|
||||
- **Website: Reveal-animation fallback** — Added a `<noscript>` style fallback and `IntersectionObserver` feature guards so scroll-revealed content can never stay invisible if JavaScript is disabled or unsupported.
|
||||
|
||||
## [v2.4.0] — 2026-06-16
|
||||
|
||||
### Added
|
||||
- **Extension: Lazy WebSocket connection** — The extension no longer maintains a permanent WebSocket connection to the relay server. Instead, the connection is established only when actively in a room or when the popup is opened with a saved room configuration. This improves privacy (IP is not exposed while idle), reduces battery/network usage, and prevents the server from tracking online status of inactive users. Automatic reconnect is guaranteed while in a room — zero behavior change during active sync sessions. See `connectIntent` flag in `background.js`.
|
||||
- **Extension: Episode title regex unification** — `extractEpisodeId()` had inconsistent regex patterns between `background.js` and `content.js`. The content script correctly matched Crunchyroll-style separators (`S01/E01`) while the service worker's stricter pattern (`[\s\-\.]*`) silently rejected them, causing episode lobby sync failures. Now unified to `[^a-zA-Z0-9]*` via shared `episode-utils.js`.
|
||||
- **Unit tests: `rate-limiter` and `episode-utils`** — 12 test groups for rate-limit functions and 30+ assertions for episode title parsing, covering all 6 separator types (dash, dot, slash, colon, comma, space). Run automatically via `npm run verify`.
|
||||
|
||||
### Changed
|
||||
- **Server: Rate limiter extracted to `rate-limiter.js`** — 6 rate-limit functions, all rate-limit Maps, and cleanup intervals moved from `index.js` (149 lines). `index.js` now imports via facade pattern with re-exports for backward compatibility.
|
||||
- **Extension: Episode utilities extracted to `episode-utils.js`** — `extractEpisodeId()` and `sameEpisode()` deduplicated from `background.js` and `content.js`. The shared module is imported as an ES module by the service worker and injected into the content script IIFE by the build script.
|
||||
- **Build: `"type": "module"` in root `package.json`** — All scripts standardized to ESM (`.mjs`) or explicitly CommonJS (`.cjs`). Eliminated Node.js `MODULE_TYPELESS_PACKAGE_JSON` warnings.
|
||||
- **Build: 4 CJS scripts renamed to `.cjs`** — `build-extension.js`, `test-content-video-finder.js`, `test-locales.js`, `website/build.js`.
|
||||
|
||||
### Fixed
|
||||
- **Server: npm audit resolved** — `ws` package vulnerability (CVE-2024-37890) fixed. Zero vulnerabilities in production dependencies.
|
||||
- **Pop-up: Connection status flicker fixed** — Removed hardcoded `disconnected` state on every pop-up open. Status now reflects actual background state from the first frame.
|
||||
- **Pop-up: Join button timeout improved** — No longer blindly re-enables after 15s. Polls connection status and extends window if still connecting.
|
||||
- **Pop-up: Validation failure state cleanup** — Custom server URL validation errors now properly reset `isProcessingConnection` and `joinBtnTimeout`.
|
||||
- **Extension: `WEB_JOIN_REQUEST` channel leak fixed** — Missing `sendResponse()` call when already in the target room.
|
||||
- **Extension: `LEAVE_ROOM` now clears `roomId` from storage** — Prevents phantom auto-reconnect on browser restart after explicit leave.
|
||||
- **Extension: Reconnect attempt counters reset on leave** — Prevents stale `reconnecting` status display after intentional disconnect.
|
||||
|
||||
## [v2.3.2] — 2026-06-16
|
||||
|
||||
### Changed
|
||||
- **Extension: Refined Spanish, Italian, and Portuguese translations**: Complete manual review and improvement of all Spanish (`es`), Italian (`it`), Portuguese — Brazil (`pt-BR`), and Portuguese — Portugal (`pt`) locale files for both the extension UI and the landing website. Thanks to [@Kaia-Alenia](https://github.com/Kaia-Alenia) for the native-quality translations.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Locale typos and corrupted characters fixed**: Repaired a Korean refresh button label (`Refreschi` → `새로고침`), a corrupted Korean connection status string (`연kel` → `연결`), a Korean character contaminating a Japanese string (`의` → `の`), and a Dutch typo (`cmmuniceren` → `communiceren`).
|
||||
- **Server: Admin token length leak fixed (timing side-channel)**: `isAdminMetricsAuthorized()` returned early when the provided buffer had a different length than the expected token, leaking the token length via response timing. Now `crypto.timingSafeEqual` runs in constant time on every attempt regardless of length match. Reported by [@Kaia-Alenia](https://github.com/Kaia-Alenia).
|
||||
|
||||
---
|
||||
|
||||
## [v2.3.1] — 2026-06-15
|
||||
|
||||
### Fixed
|
||||
- **Server: Concurrent peer join race condition and teardown error handling**
|
||||
|
||||
### Changed
|
||||
- **Server: Smart unhandled rejection handling (exits after 5/min instead of 1)**
|
||||
- **Server: Optimized admin health metrics allocation**
|
||||
|
||||
---
|
||||
|
||||
## [v2.3.0] — 2026-06-14
|
||||
|
||||
### Added
|
||||
- **Extension: New Interactive Onboarding Tour**: A fully redesigned, interactive step-by-step onboarding experience.
|
||||
@@ -84,7 +229,7 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
- **Feature Hint System**: Generic `dismissedHints` array in sync storage for announcing new features. First hint highlights the Audio Options entry in Settings. Extensible for future features.
|
||||
|
||||
### Changed
|
||||
- **Ko-Fi Support Links**: Static footer badges on the Settings and Status tabs linking to the developer's support page. README and website footer updated with Ko-Fi badge.
|
||||
- **Support Links**: Static footer badges on the Settings and Status tabs linking to the developer's support page. README and website footer updated with a Support KoalaSync badge.
|
||||
|
||||
### Fixed
|
||||
- **Portuguese (PT) locale**: Removed Italian contamination — "sincronizzazione" → "sincronização", "tempo reale" → "tempo real", "Link di Invito" → "Link de Convite", "Sair della Sala" → "Sair da Sala".
|
||||
|
||||
@@ -16,9 +16,9 @@ This guide walks through the complete user flow of KoalaSync, from creating a ro
|
||||
|
||||
## Step 2: Connecting to the Relay Server
|
||||
|
||||
When you open the extension popup, the background service worker connects to the relay server:
|
||||
When you open the extension popup (with saved room credentials) or when a saved room configuration exists from a previous session, the background service worker connects to the relay server:
|
||||
|
||||
1. **WebSocket Handshake**: `background.js` opens a WebSocket to `wss://syncserver.koalastuff.net/socket.io/?EIO=4&transport=websocket`.
|
||||
1. **WebSocket Handshake** (on demand): `background.js` opens a WebSocket to `wss://syncserver.koalastuff.net/socket.io/?EIO=4&transport=websocket` only when needed (popup opened or active room).
|
||||
2. **Security Checks** (server-side):
|
||||
- The server checks the client's **IP rate limit** (max 10 connections per 60 seconds).
|
||||
- The server validates the **authentication token** (hardcoded in `shared/constants.js`) to verify this is a legitimate KoalaSync client.
|
||||
@@ -155,8 +155,8 @@ While in a room, two heartbeats keep the session alive:
|
||||
|
||||
| Heartbeat | Interval | Source | Purpose |
|
||||
|:----------|:---------|:-------|:--------|
|
||||
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" and triggers aggressive reconnect (500ms base, max 5s) |
|
||||
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, `mediaTitle`, `playbackState`, `volume`, `muted` |
|
||||
| **Background** | 30 seconds | `background.js` | While connected, signals "I'm still connected" and triggers automatic reconnect (500ms base, max 5s). No heartbeats fire when idle (lazy connect). |
|
||||
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, privacy-filtered `mediaTitle`, `playbackState`, `volume`, `muted` |
|
||||
|
||||
- **Server Reaper**: Every 2 minutes, the server checks for peers with no activity for 5+ minutes and disconnects them ("dead peer pruning").
|
||||
- **Room Cleanup**: Empty rooms are deleted immediately. Inactive rooms are pruned after 2 hours.
|
||||
@@ -184,7 +184,7 @@ When a user clicks **"Leave"** or closes their browser:
|
||||
When watching a series and an episode ends:
|
||||
|
||||
1. `content.js` monitors the [Media Session API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API) for title changes.
|
||||
2. When a new title is detected, the peer broadcasts `EPISODE_LOBBY` with the expected new title.
|
||||
2. When a new title is detected, the peer broadcasts `EPISODE_LOBBY` with the expected title after applying the local media title privacy setting. In episode-only mode this is an identifier such as `S01E04`; when media titles are not sent, the client does not create a new episode lobby.
|
||||
3. All peers' videos freeze. The UI shows an "Episode Lobby" card with peer readiness status.
|
||||
4. Each peer's `content.js` polls for the new title to appear in the `<video>` element's metadata.
|
||||
5. Once a peer detects the matching title, they send `EPISODE_READY`.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Known Limitations & Accepted Non-Issues (NOFIX)
|
||||
|
||||
This file records design decisions that are **intentional** and **out of scope** for
|
||||
KoalaSync's threat model. They are frequently re-discovered by reviewers, linters, and
|
||||
AI agents and re-reported as "bugs" or "vulnerabilities." They are not. Please read the
|
||||
threat model below before flagging any of them.
|
||||
|
||||
> **For reviewers & agents:** Do **not** re-report the NOFIX items below. They are
|
||||
> deliberate. If you think the *threat model itself* should change (e.g. KoalaSync
|
||||
> starts handling accounts, payments, or sensitive data), raise **that** discussion
|
||||
> first — these items only stand because the threat model holds.
|
||||
|
||||
---
|
||||
|
||||
## Threat model
|
||||
|
||||
KoalaSync is a **privacy-preserving watch-party sync tool**, not a security product.
|
||||
|
||||
- **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.
|
||||
- **Participants are invited.** Anyone in a room was let in. The social contract is
|
||||
"we're watching a video together," not "mutually distrusting parties."
|
||||
|
||||
### What we DO defend against
|
||||
- **Accidental disruption** — the entire point of Host Control Mode.
|
||||
- **Spam / DoS** that degrades the relay for everyone — rate limits, 4 KB payload cap,
|
||||
server-side gating, lazy-cached health responses.
|
||||
- **Resource exhaustion / memory leaks** — bounded maps, periodic cleanup, room/peer reaping.
|
||||
- **Crashes from malformed input** — strict sanitization and clamping of every field.
|
||||
- **Genuine boundary breaches** — admin-metrics auth (constant-time), CORS, WSS upgrade,
|
||||
invite-hash isolation, strict CSP. Reports here are very welcome (see `SECURITY.md`).
|
||||
|
||||
### What we explicitly DO NOT defend against
|
||||
A **determined participant who modifies their own client to misbehave inside a room they
|
||||
were invited to.** The worst they achieve is sending playback commands or seizing the
|
||||
"host" role in a temporary room they could already disrupt by other means. That is a
|
||||
**social** problem, solved socially: kick them, or start a new room. Engineering real
|
||||
identity/auth to prevent it would destroy the account-less, frictionless, privacy-first
|
||||
design — a bad trade for an ad-hoc movie night.
|
||||
|
||||
---
|
||||
|
||||
## NOFIX entries
|
||||
|
||||
### NOFIX-1 — `peerId` is unauthenticated; a crafted client can impersonate or seize the host
|
||||
**Flag:** `peerId` is client-asserted and broadcast to every peer (in `ROOM_DATA` /
|
||||
`PEER_STATUS`). A modified client can join with the host's `peerId`, dedupe-kick the real
|
||||
host, and become host — controlling or locking `host-only` mode.
|
||||
|
||||
**Why NOFIX:** Requires a *modified client* + an *invited* participant + a `peerId` that is
|
||||
only meaningful inside that one *temporary* room. The payoff is sending play/pause or
|
||||
locking a room the attacker is already in — pure trolling, instantly reversible (kick /
|
||||
new room). Cryptographic per-user identity is wildly disproportionate for an ad-hoc,
|
||||
account-less, ephemeral watch party. **Out of threat model.**
|
||||
Do **not** "fix" with accounts, signed peerIds, or per-user tokens — that breaks the
|
||||
core design.
|
||||
|
||||
### NOFIX-2 — Room-password comparison is not constant-time
|
||||
**Flag:** room password hashes are compared with `!==` (`server/index.js`), so the compare
|
||||
is theoretically timing-attackable.
|
||||
|
||||
**Why NOFIX:** The compared value is an **HMAC-SHA256 hash that never leaves the server** —
|
||||
an attacker cannot observe it to mount a timing attack. Even a hypothetical success only
|
||||
lets someone join a *temporary* room to send playback commands. Not worth defending.
|
||||
(The admin-metrics bearer token — a real boundary — **does** use `crypto.timingSafeEqual`.
|
||||
That is the line we actually guard.)
|
||||
|
||||
### NOFIX-3 — `OFFICIAL_SERVER_TOKEN` is public in the repo
|
||||
**Flag:** the connection token in `shared/constants.js` is committed, so anyone can connect.
|
||||
|
||||
**Why NOFIX:** It is a **coarse filter** to keep random scanners off the relay, **not
|
||||
authentication**. The relay is a public message bus by design; rate limits and per-room
|
||||
behavior are the real protections.
|
||||
|
||||
### NOFIX-4 — Room IDs are enumerable via `GET_ROOMS`
|
||||
**Flag:** any connected client can list all room IDs (and whether each has a password).
|
||||
|
||||
**Why NOFIX:** This is the intended **"Public Rooms"** feature. Rooms wanting privacy set a
|
||||
password; listing the IDs of password-less rooms only lets someone join a watch party —
|
||||
the same as being handed the invite link.
|
||||
|
||||
### NOFIX-5 — A pause/seek can only be reverted, not prevented
|
||||
**Flag:** in `host-only` mode a guest's pause still fires locally before the extension can
|
||||
react, so there is a brief flicker before snap-back.
|
||||
|
||||
**Why NOFIX:** A content script cannot intercept a `<video>` event before the element
|
||||
acts. Reacting (snap-back) is the only option and is by design; the ~½s flicker is
|
||||
acceptable. Not a bug.
|
||||
|
||||
---
|
||||
|
||||
## Not NOFIX — just deferred (may be revisited)
|
||||
|
||||
These are *not* accepted-forever; they are scoped out of v1 and tracked separately
|
||||
(see the host-control-mode design docs in `docs/`):
|
||||
|
||||
- **Host grace on a long disconnect (EC-10).** A brief reconnect/second-tab keeps the host
|
||||
(handled), but a long real disconnect still falls back to `everyone`. A ~30s host-reserve
|
||||
grace could be added later.
|
||||
- **Intent-classifier / snap-back tuning.** Thresholds are first-pass; real-device testing
|
||||
may adjust them.
|
||||
+2
-2
@@ -8,7 +8,7 @@ KoalaSync is designed with a **Security-First & Volatile** architecture. This me
|
||||
|
||||
## 1. Data Processing (In-Memory Only)
|
||||
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
|
||||
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
|
||||
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId` and `username`. By default, KoalaSync also shares the selected tab title and media title with the room so peers can identify matching videos and coordinate episode transitions. Privacy Settings let you disable sending the tab title separately, and choose whether media titles are sent in full, reduced to a detected episode identifier (for example `S01E04`), or not sent. Playback metadata (`playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
|
||||
- **Room Passwords**: If you set a room password, it is stored only as an in-memory **keyed SHA-256 HMAC hash**. The server receives the plaintext password only during join validation, never stores it, and keeps only the hash for the short room lifetime.
|
||||
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
|
||||
|
||||
@@ -33,7 +33,7 @@ The browser extension requires the following permissions:
|
||||
- `storage`: To remember your local preferences (username, server URL, room settings).
|
||||
- `tabs` & `scripting`: To detect and control video elements on the pages you choose to sync.
|
||||
- `<all_urls>` (host permission): Required to detect `<video>` elements on any website the user chooses to synchronize. The extension only activates on the specific tab the user has actively selected — it does not scan, monitor, or interact with any other tabs or pages.
|
||||
- `alarms`: To keep the background service worker alive during active sync sessions.
|
||||
- `alarms`: To keep the background service worker alive during active sync sessions (the extension only stays connected while you are in a room).
|
||||
- `notifications`: To display sync status updates (e.g., "Peer joined", "Force Sync initiated").
|
||||
- **No History Access**: We do not read, store, or transmit your browsing history. We only interact with the specific tab you have actively selected for synchronization.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -5,3 +5,4 @@ This directory contains deep-dives into the KoalaSync protocol and architecture.
|
||||
- [HOW_IT_WORKS.md](HOW_IT_WORKS.md): Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md): Communication flows, Dual Heartbeat, and Sync logic.
|
||||
- [SYNC_GUIDE.md](SYNC_GUIDE.md): Protocol constants and sync requirements.
|
||||
- [TRANSLATION.md](TRANSLATION.md): Translation and localization guide for the extension and website.
|
||||
|
||||
+16
-18
@@ -30,15 +30,6 @@
|
||||
|
||||
*Prioritized for upcoming phases.*
|
||||
|
||||
### 1. Split large JavaScript files into smaller modules
|
||||
|
||||
- **Priority:** P1
|
||||
- **Category:** Maintainability / AI Context Optimization
|
||||
- **Background:** Core files like `background.js` and `popup.js` have grown large and exceed 800 lines. This makes manual debugging harder and wastes context window space for AI models.
|
||||
- **Planned solution:**
|
||||
- Structurally split logic into separate focused modules (e.g., UI Renderer, Message Router, Storage Manager, Socket Client).
|
||||
- Use ES modules for clean separation and better reusability.
|
||||
|
||||
### 2. Invite link with target URL for auto-redirect
|
||||
|
||||
- **Priority:** P2
|
||||
@@ -69,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
|
||||
@@ -79,12 +86,3 @@
|
||||
|---|---|
|
||||
| *(none yet)* | |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
*Shipped milestones.*
|
||||
|
||||
| Feature | Shipped |
|
||||
|---|---|
|
||||
| *(none yet)* | |
|
||||
|
||||
+4
-2
@@ -17,13 +17,15 @@ You MUST run the build script in any of the following scenarios:
|
||||
|
||||
Run the Node.js build script from the repository root:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
node scripts/build-extension.cjs
|
||||
# or simply:
|
||||
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/`.
|
||||
2. Injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` via marker-based replacement.
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# KoalaSync Translation & Localization Guide
|
||||
|
||||
Welcome to the **KoalaSync** translation guide. We rely on the open-source community to make KoalaSync accessible to users worldwide.
|
||||
|
||||
KoalaSync is split into two independent translation areas. You can translate either one, or both:
|
||||
|
||||
1. **The Browser Extension** (`extension/locales/`): The core product that users interact with daily.
|
||||
2. **The Website** (`website/locales/`): The landing page and invitation bridge.
|
||||
|
||||
---
|
||||
|
||||
## Supported Languages Dashboard
|
||||
|
||||
We divide supported languages into two tiers: **Core Languages** (fully hand-crafted and audited by native speakers) and **Extended Languages** (auto-generated using translation models to expand initial coverage).
|
||||
|
||||
> [!TIP]
|
||||
> **Help Us Improve!**
|
||||
> We welcome community contributions to audit `Auto-Generated` translations and elevate them to `100% Manually Verified` status.
|
||||
|
||||
| Language Code | Language Name | Verification Status | Rationale / Context |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `en` | **English** | `100% Manually Verified` | Global default language (verified by developer) |
|
||||
| `de` | **German** | `100% Manually Verified` | Developer's native language |
|
||||
| `fr` | **French** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `es` | **Spanish** | `100% Manually Verified` | Manual native review by Alenia Studios |
|
||||
| `pt-BR` | **Portuguese (Brazil)** | `100% Manually Verified` | Manual native review by Alenia Studios |
|
||||
| `ru` | **Russian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `it` | **Italian** | `100% Manually Verified` | Manual native review by Alenia Studios |
|
||||
| `pl` | **Polish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `tr` | **Turkish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `nl` | **Dutch** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ja` | **Japanese** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ko` | **Korean** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt` | **European Portuguese** | `100% Manually Verified` | Manual native review by Alenia Studios |
|
||||
| `zh` | **Chinese (Simplified)** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `uk` | **Ukrainian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
|
||||
> [!WARNING]
|
||||
> **Autogeneration Quality Rule**
|
||||
> Any newly contributed languages must be marked as `Auto-Generated` in this table until fully reviewed and signed off by a native speaker in a pull request.
|
||||
|
||||
---
|
||||
|
||||
## How to Translate KoalaSync
|
||||
|
||||
Here is the exact step-by-step process for contributing translations.
|
||||
|
||||
### Step 1: Fork and Clone the Repository
|
||||
|
||||
If you are an external contributor, start with the standard open-source workflow:
|
||||
|
||||
1. Click the "Fork" button on GitHub to create your own copy of the repository.
|
||||
2. Clone your fork locally: `git clone https://github.com/YOUR-USERNAME/KoalaSync.git`
|
||||
3. Create a branch: `git checkout -b translation/my-language`
|
||||
|
||||
### Step 2: Translate the Extension
|
||||
|
||||
The browser extension handles real-time syncing, settings, and popups.
|
||||
|
||||
1. Navigate to `extension/locales/`.
|
||||
2. Edit an existing `[lang].json` or copy `en.json` to create a new one (for example, `it.json`).
|
||||
3. Translate all string values. **Do not change the JSON keys.**
|
||||
|
||||
### Step 3: Translate the Website
|
||||
|
||||
The website hosts the landing page and invitation bridge.
|
||||
|
||||
1. Navigate to `website/locales/`.
|
||||
2. Edit an existing `[lang].json` or copy `en.json` to create a new one.
|
||||
3. Translate all string values. **Do not change the JSON keys.**
|
||||
4. If creating a brand new language, configure the metadata at the top of your JSON file:
|
||||
|
||||
```json
|
||||
{
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"CANONICAL_PATH": "it/",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN"
|
||||
}
|
||||
```
|
||||
|
||||
5. If creating a brand new language, register it in `website/build.cjs` by adding it to the `languages` array.
|
||||
|
||||
### Step 4: Verify Locally
|
||||
|
||||
Ensure your JSON files are valid and all keys match the English baseline. Open your terminal in the KoalaSync root folder and run:
|
||||
|
||||
```bash
|
||||
# Tests the extension locales for missing keys or syntax errors
|
||||
node scripts/test-locales.cjs
|
||||
|
||||
# Tests the website locales for missing keys or syntax errors
|
||||
node scripts/test-website-locales.mjs
|
||||
|
||||
# Builds the website with your new translations
|
||||
node website/build.cjs
|
||||
```
|
||||
|
||||
If you receive any errors about missing keys or placeholder strings, fix them before submitting.
|
||||
|
||||
### Step 5: Commit and Pull Request
|
||||
|
||||
1. Open this `TRANSLATION.md` file and add or update your language in the **Supported Languages Dashboard** above. Mark it as `100% Manually Verified` only if it has been reviewed by a native speaker.
|
||||
2. Commit your changes: `git commit -m "Update Italian translations"`
|
||||
3. Push to your fork: `git push origin translation/my-language`
|
||||
4. Open a pull request on the main KoalaSync repository on GitHub.
|
||||
|
||||
---
|
||||
|
||||
## Strict Legal Exclusion Rule
|
||||
|
||||
Our legal pages have strict constraints to protect user privacy and avoid regulatory liabilities.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Do Not Translate Legal Documents**
|
||||
> The legal notice (`website/impressum.html`) and privacy policy (`website/datenschutz.html`) **MUST remain exclusively in English and German**.
|
||||
>
|
||||
> **Rationale:** Legal compliance under the European Union General Data Protection Regulation (GDPR) and the German Digital Services Act (DDG). Offering automated translations of legally binding notices introduces compliance risks due to potential mistranslations of liability limits.
|
||||
>
|
||||
> **Technical fallback:** Our system automatically falls back to English for legal pages if a user visits them in an unsupported language, so you do not need to translate them.
|
||||
@@ -0,0 +1,51 @@
|
||||
# DevOps Release Workflow
|
||||
|
||||
This document describes the deployment and release process for KoalaSync.
|
||||
|
||||
## Tag-Based Releases
|
||||
|
||||
KoalaSync uses a fully automated release pipeline triggered by Git tags.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **DO NOT** manually bump the version numbers in any files (such as `package.json`, `manifest.base.json`, `shared/constants.js`, etc.) before creating a release.
|
||||
> Bumping versions manually is redundant, leads to conflicts, and is completely handled by the CI/CD pipeline.
|
||||
|
||||
### How it Works
|
||||
|
||||
When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions release workflow (`.github/workflows/release.yml`) is triggered. The workflow performs the following actions:
|
||||
|
||||
1. **Extracts the version** from the tag (e.g., `2.5.1` from `v2.5.1`).
|
||||
2. **Injects the version** automatically into the following files:
|
||||
- `extension/manifest.base.json`
|
||||
- `shared/constants.js` (updates `APP_VERSION`)
|
||||
- `package.json`
|
||||
- `website/version.json`
|
||||
- `website/template.html` (updates `softwareVersion` schema)
|
||||
- `README.md` (updates badge and announcement banner)
|
||||
- `website/sitemap.xml` (updates `lastmod` dates)
|
||||
3. **Commits and pushes** these version updates back to the `main` branch automatically with the commit message `chore(release): update versions to vX.X.X [skip ci]`.
|
||||
4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives.
|
||||
5. **Builds the website** and uploads website artifacts.
|
||||
6. **Builds and publishes** the Docker image for the relay server to the GitHub Container Registry (`ghcr.io`).
|
||||
|
||||
---
|
||||
|
||||
## Steps to Deploy a New Release
|
||||
|
||||
To release a new version (e.g., `v2.5.1`), follow these steps:
|
||||
|
||||
1. Make sure your local repository is synced on `main`:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
```
|
||||
2. Create a local Git tag:
|
||||
```bash
|
||||
git tag v2.5.1
|
||||
```
|
||||
3. Push the tag to GitHub:
|
||||
```bash
|
||||
git push origin v2.5.1
|
||||
```
|
||||
|
||||
The release pipeline will take care of the rest! You can monitor the progress under the **Actions** tab of the GitHub repository.
|
||||
@@ -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
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "scratch/**"]
|
||||
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**"]
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
@@ -37,8 +37,10 @@ export default [
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
WebSocket: "readonly",
|
||||
history: "readonly",
|
||||
location: "readonly",
|
||||
self: "readonly",
|
||||
process: "readonly"
|
||||
process: "readonly",
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
@@ -56,7 +58,7 @@ export default [
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["server/**/*.js", "scripts/**/*.js", "website/build.js"],
|
||||
files: ["server/**/*.js", "scripts/**/*.js", "scripts/**/*.cjs", "website/build.cjs"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
require: "readonly",
|
||||
|
||||
+16
-4
@@ -6,9 +6,9 @@ A Manifest V3 Browser Extension (Chrome & Firefox) for synchronized video playba
|
||||
- **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.
|
||||
- **Dual Heartbeat**: Advanced session tracking (Background) and video synchronization (Content) to prevent ghost sessions.
|
||||
- **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 6 languages (`en`, `de`, `fr`, `es`, `pt-BR`, `ru`) with auto-detected fallback and dynamic on-the-fly language selectors.
|
||||
- **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.
|
||||
|
||||
## Tab Overview
|
||||
1. **Room**: Manage connections, view active peers, and share invitation links.
|
||||
@@ -26,7 +26,7 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
|
||||
## Installation
|
||||
1. **Prepare Extension**: From the repository root, run:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
npm run build:extension
|
||||
```
|
||||
2. Open Chrome and go to `chrome://extensions/`.
|
||||
3. Enable **Developer mode** (top right).
|
||||
@@ -35,6 +35,18 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
|
||||
## Development
|
||||
If you modify `shared/constants.js`, you must synchronize the changes by running the build script from the root:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
npm run build:extension
|
||||
```
|
||||
This ensures that the `extension/shared` folder is updated with the latest protocol constants.
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincroniza la reproducción de video en YouTube, Netflix, Emby, Jellyfin y cualquier sitio HTML5 en tiempo real con amigos."
|
||||
"message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
|
||||
"message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
|
||||
"message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Синхронізуйте відтворення відео на YouTube, Netflix, Emby, Jellyfin і будь-якому сайті HTML5 у режимі реального часу з друзями."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin和任何HTML5网站上的视频播放。"
|
||||
}
|
||||
}
|
||||
@@ -126,17 +126,13 @@ function setCustomParam(param, value) {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
let audioData = (await chrome.storage.local.get(['audioSettings'])).audioSettings;
|
||||
const syncData = await chrome.storage.sync.get(['audioSettings', 'locale']);
|
||||
if (!audioData && syncData.audioSettings) {
|
||||
audioData = syncData.audioSettings;
|
||||
await chrome.storage.local.set({ audioSettings: audioData });
|
||||
}
|
||||
const lang = syncData.locale || getSystemLanguage();
|
||||
// Local-only: audioSettings/locale are never read from storage.sync.
|
||||
const { audioSettings, locale } = await chrome.storage.local.get(['audioSettings', 'locale']);
|
||||
const lang = locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
translateDOM();
|
||||
|
||||
currentSettings = mergeAudioSettings(audioData);
|
||||
currentSettings = mergeAudioSettings(audioSettings);
|
||||
render();
|
||||
}
|
||||
|
||||
|
||||
+930
-224
File diff suppressed because it is too large
Load Diff
+1638
-1053
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* KoalaSync Episode Title Utilities
|
||||
* Single source of truth — synced to content.js by build-extension.js.
|
||||
* Keep in sync with the injection block in content.js!
|
||||
*/
|
||||
|
||||
export function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
|
||||
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sameEpisode(titleA, titleB) {
|
||||
if (!titleA && !titleB) return true;
|
||||
if (!titleA || !titleB) return false;
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (idA && idB) return idA === idB;
|
||||
if (idA || idB) return false;
|
||||
return titleA === titleB;
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// extension/i18n.js
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'];
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'];
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
let activeDictionary = {};
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Teile diesen Link mit Freunden, damit sie beitreten können",
|
||||
"LABEL_PEERS_IN_ROOM": "Teilnehmer im Raum",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere Benutzer, die derzeit mit diesem Raum verbunden sind",
|
||||
"LABEL_HOST_CONTROL": "Host-Steuerung",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Festlegen, wer die Wiedergabe in diesem Raum steuern darf",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Nur ich darf die Wiedergabe steuern",
|
||||
"NOTICE_HOST_CONTROLS": "Der Host steuert die Wiedergabe für alle.",
|
||||
"NOTICE_COHOST_HINT": "Tipp: Gib einzelnen Teilnehmern unten in der Teilnehmerliste die Steuerung.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Gast",
|
||||
"BADGE_CONTROLLER": "Controller",
|
||||
"BTN_GIVE_CONTROL": "Steuerung geben",
|
||||
"BTN_REVOKE_CONTROL": "Entziehen",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Schaut alleine — ignoriert die Befehle des Hosts",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Der Host steuert diesen Raum",
|
||||
"HCM_DIALOG_BODY": "Nur der Host kann die Wiedergabe in diesem Raum steuern. Weiter gemeinsam schauen oder allein weiterschauen?",
|
||||
"HCM_DIALOG_STAY": "Synchron bleiben",
|
||||
"HCM_DIALOG_SOLO": "Allein weiterschauen",
|
||||
"HCM_BADGE_SOLO": "Du schaust allein",
|
||||
"HCM_BADGE_RESYNC": "Neu sync",
|
||||
"NO_PEERS_CONNECTED": "Keine Teilnehmer verbunden",
|
||||
"BTN_LEAVE_ROOM": "Raum verlassen",
|
||||
"LABEL_SELECT_VIDEO": "Video auswählen",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Generiere deine interne ID neu und verbinde dich erneut",
|
||||
"REGEN_ID_DESC": "Verwende dies, wenn du Fehler wegen doppelter Identität siehst.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Anderes Problem? Öffne ein GitHub Issue",
|
||||
"TOAST_ID_REGENERATED": "Identität neu generiert — Verbindung wird wiederhergestellt…",
|
||||
"LABEL_CONN_STATUS": "Verbindungsstatus",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Aktueller WebSocket-Verbindungsstatus",
|
||||
"CONN_STATUS_DISCONNECTED": "Getrennt",
|
||||
@@ -143,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "Verbinde...",
|
||||
"STATUS_FAILED": "Fehlgeschlagen",
|
||||
"STATUS_DISCONNECTED": "Getrennt",
|
||||
"STATUS_IDLE": "Bereit zum Verbinden",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync ist bereit. Tritt einem Raum bei oder erstelle einen, um die Verbindung herzustellen und die Synchronisierung zu starten.",
|
||||
"BTN_STATE_JOINING": "🚀 Trete bei...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Verbinde erneut...",
|
||||
"BTN_STATE_PLAYING": "▶ Spiele ab...",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zeitüberschreitung — nicht alle Teilnehmer haben die Episode geladen",
|
||||
"LOBBY_CANCEL_USER": "Vom Benutzer abgebrochen",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync-Fehler",
|
||||
"FOOTER_SUPPORT": "☕ Kauf mir einen Kaffee",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Bewerten",
|
||||
"FOOTER_SUPPORT_PROMPT": "Gefällt dir KoalaSync? Hinterlasse eine Bewertung!",
|
||||
"LABEL_AUDIO_PROCESSING": "Audio-Verarbeitung",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Demnächst",
|
||||
"BTN_RESTART_TOUR": "Einführung neu starten",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Startet die Einführung für neue Benutzer erneut",
|
||||
"HINT_SELECT_VIDEO": "Wähle hier dein Video aus!"
|
||||
"HINT_SELECT_VIDEO": "Wähle hier dein Video aus!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Voll senden",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Nur Episoden-ID senden",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Nicht senden",
|
||||
"LABEL_PRIVACY_SETTINGS": "Privatsphäre-Einstellungen",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Lege fest, welche Tab- und Medientitel an den Raum gesendet werden.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Share this link with friends so they can join",
|
||||
"LABEL_PEERS_IN_ROOM": "Peers in Room",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Other users currently connected to this room",
|
||||
"LABEL_HOST_CONTROL": "Host Control",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Restrict who can control playback in this room",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Only I can control playback",
|
||||
"NOTICE_HOST_CONTROLS": "The host controls playback for everyone.",
|
||||
"NOTICE_COHOST_HINT": "Tip: grant control to individual viewers in the participant list below.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Guest",
|
||||
"BADGE_CONTROLLER": "Controller",
|
||||
"BTN_GIVE_CONTROL": "Give control",
|
||||
"BTN_REVOKE_CONTROL": "Revoke",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Watching on their own — host's commands are ignored",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Host controls this room",
|
||||
"HCM_DIALOG_BODY": "Only the host can control playback in this room. Keep watching together, or watch on your own?",
|
||||
"HCM_DIALOG_STAY": "Stay in sync",
|
||||
"HCM_DIALOG_SOLO": "Watch on my own",
|
||||
"HCM_BADGE_SOLO": "Watching on your own",
|
||||
"HCM_BADGE_RESYNC": "Resync",
|
||||
"NO_PEERS_CONNECTED": "No peers connected",
|
||||
"BTN_LEAVE_ROOM": "Leave Room",
|
||||
"LABEL_SELECT_VIDEO": "Select Video",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerate your internal ID and reconnect",
|
||||
"REGEN_ID_DESC": "Use this if you see \"Duplicate Identity\" errors.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Other issue? Open a GitHub Issue",
|
||||
"TOAST_ID_REGENERATED": "Identity regenerated — reconnecting…",
|
||||
"LABEL_CONN_STATUS": "Connection Status",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Current WebSocket connection state",
|
||||
"CONN_STATUS_DISCONNECTED": "Disconnected",
|
||||
@@ -143,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "Connecting...",
|
||||
"STATUS_FAILED": "Failed",
|
||||
"STATUS_DISCONNECTED": "Disconnected",
|
||||
"STATUS_IDLE": "Ready to connect",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync is ready. Join or create a room to connect and start syncing.",
|
||||
"BTN_STATE_JOINING": "🚀 Joining...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconnecting...",
|
||||
"BTN_STATE_PLAYING": "▶ Playing...",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — not all peers loaded the episode",
|
||||
"LOBBY_CANCEL_USER": "Cancelled by user",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync Error",
|
||||
"FOOTER_SUPPORT": "☕ Buy me a coffee",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Rate us",
|
||||
"FOOTER_SUPPORT_PROMPT": "Enjoying KoalaSync? Leave a review!",
|
||||
"LABEL_AUDIO_PROCESSING": "Audio Processing",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Coming soon",
|
||||
"BTN_RESTART_TOUR": "Restart Tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Restart the onboarding tutorial",
|
||||
"HINT_SELECT_VIDEO": "Select your video here!"
|
||||
"HINT_SELECT_VIDEO": "Select your video here!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Send full title",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Send episode ID",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Do not send",
|
||||
"LABEL_PRIVACY_SETTINGS": "Privacy Settings",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Choose which tab and media titles are sent to the room.",
|
||||
"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."
|
||||
}
|
||||
|
||||
+56
-26
@@ -6,7 +6,7 @@
|
||||
"TAB_ROOM_TOOLTIP": "Configuración de sala y conexión",
|
||||
"TAB_SYNC": "Sincro",
|
||||
"TAB_SYNC_TOOLTIP": "Controles de sincronización de video y acciones",
|
||||
"TAB_SETTINGS": "Opciones",
|
||||
"TAB_SETTINGS": "Configuración",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferencias de la extensión",
|
||||
"TAB_STATUS": "Estado",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnósticos avanzados y registros",
|
||||
@@ -15,7 +15,7 @@
|
||||
"LABEL_SERVER": "Servidor",
|
||||
"BTN_SERVER_OFFICIAL": "Oficial",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar el servidor oficial confiable",
|
||||
"BTN_SERVER_CUSTOM": "Perso",
|
||||
"BTN_SERVER_CUSTOM": "Personalizado",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectarse a su propio servidor auto-alojado",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://tu-servidor:3000",
|
||||
"LABEL_ROOM_ID": "ID de sala",
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Comparte este enlace con amigos para que se unan",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes en la sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Otros usuarios conectados actualmente a esta sala",
|
||||
"LABEL_HOST_CONTROL": "Control del anfitrión",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Restringir quién puede controlar la reproducción en esta sala",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Solo yo puedo controlar la reproducción",
|
||||
"NOTICE_HOST_CONTROLS": "El anfitrión controla la reproducción para todos.",
|
||||
"NOTICE_COHOST_HINT": "Consejo: concede el control a usuarios concretos en la lista de participantes de abajo.",
|
||||
"BADGE_HOST": "Anfitrión",
|
||||
"BADGE_GUEST": "Invitado",
|
||||
"BADGE_CONTROLLER": "Controlador",
|
||||
"BTN_GIVE_CONTROL": "Dar control",
|
||||
"BTN_REVOKE_CONTROL": "Quitar",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Viendo por su cuenta — ignora los comandos del anfitrión",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · El anfitrión controla esta sala",
|
||||
"HCM_DIALOG_BODY": "Solo el anfitrión puede controlar la reproducción en esta sala. ¿Seguir viendo juntos o ver por tu cuenta?",
|
||||
"HCM_DIALOG_STAY": "Seguir sincronizado",
|
||||
"HCM_DIALOG_SOLO": "Ver por mi cuenta",
|
||||
"HCM_BADGE_SOLO": "Viendo por tu cuenta",
|
||||
"HCM_BADGE_RESYNC": "Resincronizar",
|
||||
"NO_PEERS_CONNECTED": "Sin participantes conectados",
|
||||
"BTN_LEAVE_ROOM": "Salir de la sala",
|
||||
"LABEL_SELECT_VIDEO": "Seleccionar video",
|
||||
@@ -58,16 +76,16 @@
|
||||
"BTN_PAUSE_TOOLTIP": "Enviar un comando de pausa a todos",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forzar la sincronización de todos los usuarios",
|
||||
"OPTION_JUMP_TO_OTHERS": "Unirse a otros",
|
||||
"OPTION_JUMP_TO_ME": "Traerlos a mí",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir a la posición de otros",
|
||||
"OPTION_JUMP_TO_ME": "Traerlos a mi posición",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Elegir el objetivo de sincronización",
|
||||
"LABEL_LAST_ACTIVITY": "Última actividad",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Muestra el comando más reciente de reproducción, pausa o búsqueda",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Muestra el comando más reciente de reproducción, pausa o salto en el tiempo",
|
||||
"NO_RECENT_COMMANDS": "Sin comandos recientes",
|
||||
"LOBBY_HEADER": "SALA DE ESPERA DE EPISODIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 Esperando a: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Esperando participantes...",
|
||||
"BTN_SKIP_PLAY": "Omitir y reproducir",
|
||||
"BTN_SKIP_PLAY": "Omitir y reproducir ya",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar la sala de espera y reproducir de todos modos",
|
||||
"LOBBY_CONNECT_FIRST": "Únete a una sala primero",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Necesitas unirte a una sala a través de un enlace de invitación o crear una nueva para sincronizar videos.",
|
||||
@@ -76,7 +94,7 @@
|
||||
"LABEL_USERNAME": "Tu nombre de usuario",
|
||||
"LABEL_USERNAME_TOOLTIP": "El nombre de usuario ayuda a otros a identificarte.",
|
||||
"PLACEHOLDER_USERNAME": "Koala anónimo",
|
||||
"LABEL_HIDE_CLUTTER": "Lista de pestañas limpia",
|
||||
"LABEL_HIDE_CLUTTER": "Ocultar pestañas sin video",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra pestañas que no son de video y dominios no relacionados para mantener limpia la lista",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sincro auto del siguiente episodio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automáticamente y espera a todos al cambiar de episodio, luego inicia de forma sincrónica.",
|
||||
@@ -88,10 +106,11 @@
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Elige tu idioma preferido para la extensión",
|
||||
"LABEL_TROUBLESHOOTING": "Resolución de problemas",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Herramientas para solucionar problemas de conexión",
|
||||
"BTN_REGEN_ID": "Regenerar ID de participante",
|
||||
"BTN_REGEN_ID": "Regenerar ID de usuario",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerar tu ID interno y volver a conectarte",
|
||||
"REGEN_ID_DESC": "Usa esto si ves errores de 'Identidad duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "¿Otro problema? Abre un Issue en GitHub",
|
||||
"REGEN_ID_OTHER_ISSUE": "¿Tienes otro problema? Abre un reporte en GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identidad regenerada — reconectando…",
|
||||
"LABEL_CONN_STATUS": "Estado de la conexión",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Estado actual de la conexión WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desconectado",
|
||||
@@ -115,7 +134,7 @@
|
||||
"BTN_ONBOARDING_NEXT": "Siguiente",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir al siguiente paso",
|
||||
"ONBOARDING_1_TITLE": "¡Bienvenido a KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Mira videos juntos en perfecta sincronización, sin importar dónde estén. ¡Hagamos un recorrido rápido!",
|
||||
"ONBOARDING_1_TEXT": "Mira videos junto a tus amigos en perfecta sincronización, sin importar dónde estén. ¡Hagamos un recorrido rápido!",
|
||||
"ONBOARDING_2_TITLE": "1. Crear una sala",
|
||||
"ONBOARDING_2_TEXT": "Comienza aquí. Crea una sala y comparte el enlace de invitación con tus amigos.",
|
||||
"ONBOARDING_3_TITLE": "2. Seleccionar video",
|
||||
@@ -127,7 +146,7 @@
|
||||
"ERR_CONN_TIMEOUT": "Tiempo de conexión agotado. Inténtalo de nuevo.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato de URL de servidor no válido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidad no cargada aún. Espera un momento e inténtalo de nuevo.",
|
||||
"ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mí'.",
|
||||
"ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mi posición'.",
|
||||
"ERR_NO_VIDEO_TAB": "No se pudo conectar a la pestaña de video.",
|
||||
"ERR_SELECT_VIDEO": "¡Selecciona un video primero!",
|
||||
"TOAST_INVITE_COPIED": "¡Enlace de invitación copiado!",
|
||||
@@ -135,14 +154,16 @@
|
||||
"TOAST_LOBBY_SKIPPED": "Sala de espera de episodio omitida.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Error al omitir la sala de espera.",
|
||||
"TOAST_LOGS_COPIED": "¡Copiado!",
|
||||
"TOAST_PEER_JOINED": "{name} se unió a la sala",
|
||||
"TOAST_PEER_LEFT": "{name} salió de la sala",
|
||||
"TOAST_PEER_JOINED": "{name} se ha unido a la sala",
|
||||
"TOAST_PEER_LEFT": "{name} ha salido de la sala",
|
||||
"TOAST_PEER_ACTION": "{name} ha {action}",
|
||||
"STATUS_CONNECTED": "Conectado",
|
||||
"STATUS_RECONNECTING": "Reconectando...",
|
||||
"STATUS_CONNECTING": "Conectando...",
|
||||
"STATUS_FAILED": "Error",
|
||||
"STATUS_DISCONNECTED": "Desconectado",
|
||||
"STATUS_IDLE": "Listo para conectar",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync está listo. Únete o crea una sala para conectarte y empezar a sincronizar.",
|
||||
"BTN_STATE_JOINING": "🚀 Uniéndose...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
|
||||
"BTN_STATE_PLAYING": "▶ Reproduciendo...",
|
||||
@@ -150,17 +171,17 @@
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizando al grupo ({time})...",
|
||||
"BTN_STATE_SYNCING": "Sincronizando...",
|
||||
"BTN_STATE_SYNCED": "✅ ¡Sincronizado!",
|
||||
"NOTIF_PLAY": "inició la reproducción",
|
||||
"NOTIF_PAUSE": "pausó la reproducción",
|
||||
"NOTIF_SEEK": "cambió la posición del video",
|
||||
"NOTIF_FORCE_PREPARE": "inició una sincronización forzada",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizó a todos",
|
||||
"NOTIF_PLAY": "ha iniciado la reproducción",
|
||||
"NOTIF_PAUSE": "ha pausado la reproducción",
|
||||
"NOTIF_SEEK": "ha cambiado la posición del video",
|
||||
"NOTIF_FORCE_PREPARE": "ha iniciado una sincronización forzada",
|
||||
"NOTIF_FORCE_EXECUTE": "ha sincronizado a todos",
|
||||
"DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.",
|
||||
"DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.",
|
||||
"EMPTY_PEERS_TITLE": "Sin participantes aún",
|
||||
"EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar",
|
||||
"EMPTY_HISTORY_TITLE": "Sin actividad aún",
|
||||
"EMPTY_HISTORY_HINT": "Reproduce, pausa o busca para ver el historial",
|
||||
"EMPTY_HISTORY_HINT": "Reproduce, pausa o salta en el tiempo para ver el historial",
|
||||
"EMPTY_LOGS_TITLE": "Sin registros",
|
||||
"EMPTY_LOGS_HINT": "Los eventos de conexión aparecerán aquí",
|
||||
"EMPTY_ROOMS_TITLE": "Sin salas activas",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tiempo de espera agotado: no todos los participantes cargaron el episodio",
|
||||
"LOBBY_CANCEL_USER": "Cancelado por el usuario",
|
||||
"NOTIF_ERROR_TITLE": "Error de KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Invítame a un café",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Valorar",
|
||||
"FOOTER_SUPPORT_PROMPT": "¿Te gusta KoalaSync? ¡Deja una reseña!",
|
||||
"LABEL_AUDIO_PROCESSING": "Procesamiento de audio",
|
||||
@@ -199,14 +220,23 @@
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Mejora de voz",
|
||||
"AUDIO_PRESET_SMOOTH": "Suave",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizado",
|
||||
"AUDIO_PARAM_THRESHOLD": "Umbral",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_PARAM_THRESHOLD": "Umbral (Threshold)",
|
||||
"AUDIO_PARAM_KNEE": "Codo (Knee)",
|
||||
"AUDIO_PARAM_RATIO": "Relación (Ratio)",
|
||||
"AUDIO_PARAM_ATTACK": "Ataque (Attack)",
|
||||
"AUDIO_PARAM_RELEASE": "Liberación (Release)",
|
||||
"AUDIO_EQUALIZER": "Ecualizador",
|
||||
"AUDIO_COMING_SOON": "Próximamente",
|
||||
"BTN_RESTART_TOUR": "Reiniciar tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar el tutorial de inicio",
|
||||
"HINT_SELECT_VIDEO": "¡Selecciona tu video aquí!"
|
||||
"HINT_SELECT_VIDEO": "¡Selecciona tu video aquí!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Enviar título completo",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Enviar ID de episodio",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "No enviar",
|
||||
"LABEL_PRIVACY_SETTINGS": "Ajustes de privacidad",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Elige qué títulos de pestaña y multimedia se envían a la sala.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Partagez ce lien avec vos amis pour qu'ils vous rejoignent",
|
||||
"LABEL_PEERS_IN_ROOM": "Membres dans le salon",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Autres utilisateurs connectés à ce salon",
|
||||
"LABEL_HOST_CONTROL": "Contrôle de l'hôte",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Définir qui peut contrôler la lecture dans ce salon",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Moi seul peux contrôler la lecture",
|
||||
"NOTICE_HOST_CONTROLS": "L'hôte contrôle la lecture pour tout le monde.",
|
||||
"NOTICE_COHOST_HINT": "Astuce : donnez le contrôle à des participants précis dans la liste ci-dessous.",
|
||||
"BADGE_HOST": "Hôte",
|
||||
"BADGE_GUEST": "Invité",
|
||||
"BADGE_CONTROLLER": "Contrôleur",
|
||||
"BTN_GIVE_CONTROL": "Donner le contrôle",
|
||||
"BTN_REVOKE_CONTROL": "Retirer",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Regarde de son côté — ignore les commandes de l'hôte",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · L'hôte contrôle ce salon",
|
||||
"HCM_DIALOG_BODY": "Seul l'hôte peut contrôler la lecture dans ce salon. Continuer à regarder ensemble, ou regarder de votre côté ?",
|
||||
"HCM_DIALOG_STAY": "Rester synchronisé",
|
||||
"HCM_DIALOG_SOLO": "Regarder de mon côté",
|
||||
"HCM_BADGE_SOLO": "Vous regardez seul",
|
||||
"HCM_BADGE_RESYNC": "Resynchroniser",
|
||||
"NO_PEERS_CONNECTED": "Aucun membre connecté",
|
||||
"BTN_LEAVE_ROOM": "Quitter le salon",
|
||||
"LABEL_SELECT_VIDEO": "Choisir une vidéo",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Régénérer votre identifiant interne et vous reconnecter",
|
||||
"REGEN_ID_DESC": "Utilisez cette option si vous rencontrez des erreurs de 'Double identité'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Autre problème? Ouvrez un Issue GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identité régénérée — reconnexion…",
|
||||
"LABEL_CONN_STATUS": "Statut de la connexion",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Statut actuel de la connexion WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Déconnecté",
|
||||
@@ -143,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "Connexion...",
|
||||
"STATUS_FAILED": "Échec",
|
||||
"STATUS_DISCONNECTED": "Déconnecté",
|
||||
"STATUS_IDLE": "Prêt à se connecter",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync est prêt. Rejoignez ou créez un salon pour vous connecter et commencer la synchronisation.",
|
||||
"BTN_STATE_JOINING": "🚀 Connexion...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconnexion...",
|
||||
"BTN_STATE_PLAYING": "▶ Lecture...",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Délai dépassé — tous les membres n'ont pas chargé l'épisode",
|
||||
"LOBBY_CANCEL_USER": "Annulé par l'utilisateur",
|
||||
"NOTIF_ERROR_TITLE": "Erreur KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Offre-moi un café",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Évaluer",
|
||||
"FOOTER_SUPPORT_PROMPT": "Tu aimes KoalaSync? Laisse un avis!",
|
||||
"LABEL_AUDIO_PROCESSING": "Traitement audio",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Bientôt disponible",
|
||||
"BTN_RESTART_TOUR": "Redémarrer le tutoriel",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Recommencer le tutoriel d'intégration",
|
||||
"HINT_SELECT_VIDEO": "Sélectionnez votre vidéo ici !"
|
||||
"HINT_SELECT_VIDEO": "Sélectionnez votre vidéo ici !",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Envoyer le titre complet",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Envoyer l’identifiant d’épisode",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Ne pas envoyer",
|
||||
"LABEL_PRIVACY_SETTINGS": "Paramètres de confidentialité",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Choisissez quels titres d’onglet et de média sont envoyés au salon.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
# KoalaSync Browser Extension v2.0 - i18n Technical Implementation Plan
|
||||
|
||||
Welcome, future Antigravity AI agent! This document is placed directly in the codebase at `/extension/locales/i18n_plan.md` to serve as a comprehensive architectural handbook for the next steps in adding **full internationalization (i18n) support to the browser extension itself** while maintaining 100% video-sync and background communication safety.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Context & Audited Scope
|
||||
|
||||
KoalaSync is a lightweight, premium browser extension (Chrome & Firefox Manifest V3) for synchronized video playback. The landing pages are already compiled dynamically in 6 languages:
|
||||
* English (`en`)
|
||||
* German (`de`)
|
||||
* French (`fr`)
|
||||
* Spanish (`es`)
|
||||
* Portuguese (Brazil) (`pt-BR`)
|
||||
* Russian (`ru`)
|
||||
|
||||
Our goal is to build an identical, premium translation engine for the extension itself.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architectural Choice: Custom JSON Dictionary Engine (Approach B)
|
||||
|
||||
We evaluated two paths:
|
||||
1. **Approach A (Native `chrome.i18n` API):** Uses `_locales/` directory. Too rigid—cannot support real-time dynamic switching inside the extension Settings dropdown without closing/re-opening the popup.
|
||||
2. **Approach B (Custom Unified JSON Engine):** Uses flat JSON files matching our website files (`"KEY": "Value"`). Dynamically merges the target dictionary with baseline English `en.json` at runtime, programmatically providing an **airtight English fallback** and **real-time DOM translations** without popup reload.
|
||||
|
||||
We chose **Approach B** for maximum compatibility, premium real-time toggling, and clean fallback safety.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Resolve, Load, & State Flow
|
||||
|
||||
1. **On launch:** Look for saved language in `chrome.storage.sync.get('locale')`.
|
||||
2. **Fallback Autodetect:** If no saved language, read `navigator.language` or `chrome.i18n.getUILanguage()`.
|
||||
* If the detected locale is supported, set as active.
|
||||
* If not supported, default to English (`en`).
|
||||
3. **Dictionary Resolution:**
|
||||
* Asynchronously load the English baseline dictionary (`/extension/locales/en.json`).
|
||||
* If the target language is different, load target JSON (e.g. `/extension/locales/de.json`) and execute `Object.assign({}, enDict, targetDict)`. This guarantees dynamic translation while cleanly falling back to English for any missing keys.
|
||||
4. **DOM Replacements:** Scan for `data-i18n`, `data-i18n-title`, and `data-i18n-placeholder` attributes, and translate them on the fly.
|
||||
5. **Persistence:** Save dynamic selection modifications from the dropdown into `chrome.storage.sync`. Trigger instant DOM re-translation on change.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Proposed File Structure
|
||||
|
||||
```
|
||||
KoalaPlay/
|
||||
└── extension/
|
||||
├── locales/ # [NEW] Contains flat translation maps
|
||||
│ ├── i18n_plan.md # This roadmap file
|
||||
│ ├── en.json # Flat English baseline keys
|
||||
│ ├── de.json # German keys
|
||||
│ ├── fr.json # French keys
|
||||
│ ├── es.json # Spanish keys
|
||||
│ ├── pt-BR.json # Portuguese (Brasil) keys
|
||||
│ └── ru.json # Russian keys
|
||||
├── i18n.js # [NEW] ESM translation engine module
|
||||
├── popup.html # Modified with data-i18n attributes
|
||||
├── popup.js # Modified to initialize locales and update variables
|
||||
└── background.js # Modified to push localized notification alerts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Draft Code Snippets
|
||||
|
||||
### 1. `i18n.js` (Zero-Dependency Engine Module)
|
||||
```javascript
|
||||
// extension/i18n.js
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
let activeDictionary = {};
|
||||
|
||||
export async function loadLocale(langCode) {
|
||||
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
|
||||
try {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
const enDict = await enResponse.json();
|
||||
|
||||
if (resolvedLang === DEFAULT_LANGUAGE) {
|
||||
activeDictionary = enDict;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetResponse = await fetch(chrome.runtime.getURL(`locales/${resolvedLang}.json`));
|
||||
const targetDict = await targetResponse.json();
|
||||
|
||||
activeDictionary = Object.assign({}, enDict, targetDict);
|
||||
} catch (err) {
|
||||
console.error('[i18n] Failed to load dictionary. Falling back to English:', err);
|
||||
const rescue = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
activeDictionary = await rescue.json();
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessage(key) {
|
||||
return activeDictionary[key] || key;
|
||||
}
|
||||
|
||||
export function translateDOM() {
|
||||
// Translate text nodes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translated = getMessage(key);
|
||||
const img = el.querySelector('img');
|
||||
if (img) {
|
||||
el.innerHTML = '';
|
||||
el.appendChild(img);
|
||||
el.appendChild(document.createTextNode(' ' + translated));
|
||||
} else {
|
||||
el.textContent = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Translate tooltips
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.setAttribute('title', getMessage(key));
|
||||
});
|
||||
|
||||
// Translate placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.setAttribute('placeholder', getMessage(key));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Markup Changes (`popup.html`)
|
||||
* Annotate text elements: `<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP">Settings</button>`
|
||||
* Add Language Dropdown in the Settings Panel:
|
||||
```html
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0;" data-i18n="LABEL_LANGUAGE" title="Choose your preferred extension language">App Language</label>
|
||||
<select id="langSelector" style="width: 150px; padding: 6px 10px; font-size: 13px; cursor: pointer;">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="pt-BR">Português (Brasil)</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Dynamic Script Wiring (`popup.js`)
|
||||
* Import our engine: `import { loadLocale, translateDOM, getMessage } from './i18n.js';`
|
||||
* Initialize during startup:
|
||||
```javascript
|
||||
async function init() {
|
||||
const data = await chrome.storage.sync.get(['locale', ...]);
|
||||
let activeLang = data.locale;
|
||||
if (!activeLang) {
|
||||
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
|
||||
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
|
||||
chrome.storage.sync.set({ locale: activeLang });
|
||||
}
|
||||
await loadLocale(activeLang);
|
||||
translateDOM();
|
||||
|
||||
// Select matching option in selector
|
||||
const langSelector = document.getElementById('langSelector');
|
||||
if (langSelector) langSelector.value = activeLang;
|
||||
|
||||
// rest of standard init...
|
||||
}
|
||||
```
|
||||
* Listen to Settings change event:
|
||||
```javascript
|
||||
const langSelector = document.getElementById('langSelector');
|
||||
if (langSelector) {
|
||||
langSelector.addEventListener('change', async () => {
|
||||
const selectedLang = langSelector.value;
|
||||
await chrome.storage.sync.set({ locale: selectedLang });
|
||||
await loadLocale(selectedLang);
|
||||
translateDOM();
|
||||
// Re-render empty elements and tab list using new dynamic strings
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
populateTabs();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Background Notifications (`background.js`)
|
||||
* Notifications inside `showNotification` should fetch dynamic keys based on `chrome.storage.sync` language settings and build clean alerts dynamically using `chrome.storage.sync.get('locale')`.
|
||||
* Ensure that WebSocket event transmissions and video player scripts in `content.js` remain **completely untouched and unaware** of the localization engine.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Safety Guardrail
|
||||
|
||||
* **WebSocket Engine Integrity:** Under no circumstances should `background.js` Socket.IO handshakes, rate limits, or binary protocol headers be adjusted. i18n is strictly an interface rendering skin layer.
|
||||
* **Player Control Pipeline Integrity:** `content.js` expectedEvent suppression tables, HLS buffer calculations, and Media Session API listeners must remain untouched. Do not bind any i18n logic into the content script's active tracking loop.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verification Tasks
|
||||
|
||||
1. **Autodetection Audit:** Force-load the extension in Chrome/Firefox in a non-English language container and ensure it starts in that language or English gracefully.
|
||||
2. **Integrity Validation Script:** Write a sanity node checker script `/scripts/test-locales.js` to ensure all key structures are uniform across all JSON files.
|
||||
3. **Real-Time Refresh Verification:** Change dynamic drop-down preferences and verify that every element and alert swaps languages cleanly with zero redraw glitches or page crashes.
|
||||
+135
-105
@@ -3,8 +3,8 @@
|
||||
"HTML_CLASS": "lang-it",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Stanza",
|
||||
"TAB_ROOM_TOOLTIP": "Impostazioni della stanza e connessione",
|
||||
"TAB_SYNC": "Sincronizza",
|
||||
"TAB_ROOM_TOOLTIP": "Impostazioni della stanza y connessione",
|
||||
"TAB_SYNC": "Sincro",
|
||||
"TAB_SYNC_TOOLTIP": "Controlli di sincronizzazione video e azioni remote",
|
||||
"TAB_SETTINGS": "Impostazioni",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferenze dell'estensione",
|
||||
@@ -16,155 +16,176 @@
|
||||
"BTN_SERVER_OFFICIAL": "Ufficiale",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usa il server ufficiale affidabile",
|
||||
"BTN_SERVER_CUSTOM": "Personalizzato",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Connettiti al tuo server ospitato autonomamente",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Connettiti al tuo server privato",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://tuo-server:3000",
|
||||
"LABEL_ROOM_ID": "ID Stanza",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "L'identificatore univoco per la tua stanza di sincronizzazione",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "L'identificatore univoco per la tua stanza",
|
||||
"PLACEHOLDER_ROOM_ID": "Inserisci ID Stanza",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID univoco della stanza a cui vuoi unirti",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID della stanza a cui vuoi unirti",
|
||||
"LABEL_PASSWORD": "Password (Opzionale)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Password opzionale per limitare l'accesso alla stanza",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Password per limitare l'accesso alla stanza",
|
||||
"PLACEHOLDER_PASSWORD": "Password della stanza (opzionale)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password per la stanza (lascia vuoto se nessuna)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Lascia vuoto se non c'è una password",
|
||||
"BTN_JOIN_ROOM": "Entra / Crea Stanza",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Connettiti alla stanza",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Entra nella stanza",
|
||||
"LABEL_PUBLIC_ROOMS": "Stanze Pubbliche",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubblicamente disponibili su questo server",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubbliche su questo server",
|
||||
"BTN_REFRESH": "AGGIORNA",
|
||||
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze pubbliche",
|
||||
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Aggiornamento...",
|
||||
"BTN_REFRESH_COOLDOWN": "ATTENDI {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "L'aggiornamento dell'elenco è in pausa. Riprova tra {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento stanze pubbliche. Prossimo aggiornamento disponibile tra {seconds}s.",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Riprova tra {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento in corso. Disponibile tra {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Stanza Attiva",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei attualmente connesso",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei connesso",
|
||||
"ACTIVE_ROOM_NONE": "NESSUNA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Server Ufficiale",
|
||||
"LABEL_INVITE_LINK": "Link di Invito",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Condividi questo link con gli amici per farli entrare",
|
||||
"LABEL_PEERS_IN_ROOM": "Partecipanti nella Stanza",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Altri utenti attualmente connessi a questa stanza",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Condividi questo link per invitare i tuoi amici",
|
||||
"LABEL_PEERS_IN_ROOM": "Partecipanti",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Utenti connessi a questa stanza",
|
||||
"LABEL_HOST_CONTROL": "Controllo host",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Limita chi può controllare la riproduzione in questa stanza",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Solo io posso controllare la riproduzione",
|
||||
"NOTICE_HOST_CONTROLS": "L'host controlla la riproduzione per tutti.",
|
||||
"NOTICE_COHOST_HINT": "Suggerimento: concedi il controllo ai singoli partecipanti nell'elenco qui sotto.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Ospite",
|
||||
"BADGE_CONTROLLER": "Controller",
|
||||
"BTN_GIVE_CONTROL": "Dai il controllo",
|
||||
"BTN_REVOKE_CONTROL": "Revoca",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Guarda per conto proprio — ignora i comandi dell'host",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · L'host controlla questa stanza",
|
||||
"HCM_DIALOG_BODY": "Solo l'host può controllare la riproduzione in questa stanza. Continuare a guardare insieme o guardare per conto tuo?",
|
||||
"HCM_DIALOG_STAY": "Resta sincronizzato",
|
||||
"HCM_DIALOG_SOLO": "Guarda per conto mio",
|
||||
"HCM_BADGE_SOLO": "Stai guardando da solo",
|
||||
"HCM_BADGE_RESYNC": "Risincronizza",
|
||||
"NO_PEERS_CONNECTED": "Nessun partecipante connesso",
|
||||
"BTN_LEAVE_ROOM": "Lascia Stanza",
|
||||
"LABEL_SELECT_VIDEO": "Seleziona Video",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Scegli la scheda del browser contenente il video da sincronizzare",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Scegli la scheda con il video da sincronizzare",
|
||||
"OPTION_SELECT_TAB": "-- Seleziona una Scheda --",
|
||||
"LABEL_REMOTE_CONTROL": "Controllo Remoto",
|
||||
"LABEL_REMOTE_CONTROL": "Telecomando",
|
||||
"BTN_COPY_INVITE": "📋 Link di Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copia Link di Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copia il link di invito",
|
||||
"BTN_PLAY": "▶ Riproduci",
|
||||
"BTN_PLAY_TOOLTIP": "Invia un comando di riproduzione a tutti",
|
||||
"BTN_PLAY_TOOLTIP": "Avvia la riproduzione per tutti",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Invia un comando di pausa a tutti",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione di tutti gli utenti",
|
||||
"OPTION_JUMP_TO_OTHERS": "Salto dagli Altri",
|
||||
"OPTION_JUMP_TO_ME": "Salto da Me",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Scegli il target di sincronizzazione",
|
||||
"LABEL_LAST_ACTIVITY": "Stato Ultima Attività",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra il comando più recente di riproduzione, pausa o ricerca",
|
||||
"BTN_PAUSE_TOOLTIP": "Metti in pausa per tutti",
|
||||
"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",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Scegli l'obiettivo di sincronizzazione",
|
||||
"LABEL_LAST_ACTIVITY": "Ultima Attività",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra l'ultimo comando inviato",
|
||||
"NO_RECENT_COMMANDS": "Nessun comando recente",
|
||||
"LOBBY_HEADER": "LOBBY EPISODIO",
|
||||
"LOBBY_HEADER": "SALA D'ATTESA EPISODIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 In attesa di: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "In attesa dei partecipanti...",
|
||||
"BTN_SKIP_PLAY": "Salta e riproduci comunque",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Annulla la lobby e riproduci comunque",
|
||||
"LOBBY_CONNECT_FIRST": "Connettiti prima a una stanza",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Devi unirti a una stanza tramite un link di invito o crearne una nuova per sincronizzare i video.",
|
||||
"BTN_SKIP_PLAY": "Salta e riproduci ora",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Annulla l'attesa e riproduci comunque",
|
||||
"LOBBY_CONNECT_FIRST": "Entra prima in una stanza",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Devi unirti a una stanza o crearne una nuova per sincronizzare i video.",
|
||||
"BTN_CREATE_ROOM_ALT": "Crea Nuova Stanza",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crea una nuova stanza casuale ed entra",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crea una stanza casuale ed entra",
|
||||
"LABEL_USERNAME": "Tuo Nome Utente",
|
||||
"LABEL_USERNAME_TOOLTIP": "Il nome utente aiuta gli altri a identificarti.",
|
||||
"LABEL_USERNAME_TOOLTIP": "Il tuo nome visibile agli altri.",
|
||||
"PLACEHOLDER_USERNAME": "Koala Anonimo",
|
||||
"LABEL_HIDE_CLUTTER": "Nascondi Schede Inutili",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra le schede senza video e i domini non correlati per mantenere pulito l'elenco",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Auto-Sync Prossimo Episodio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e attende tutti i partecipanti al cambio episodio, poi si avvia in sincro.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Copia Automatica Invito",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente il link di invito negli appunti quando crei una nuova stanza.",
|
||||
"LABEL_HIDE_CLUTTER": "Nascondi schede senza video",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Mantiene pulito l'elenco filtrando le schede non pertinenti",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sincro auto prossimo episodio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Attende tutti i partecipanti prima di avviare il prossimo episodio.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Copia automatica invito",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia il link negli appunti quando crei una nuova stanza.",
|
||||
"LABEL_NOTIFICATIONS": "Notifiche del Browser",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra le notifiche di sistema quando qualcuno entra/esce o riproduce/mette in pausa.",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notifiche quando qualcuno entra, esce o cambia stato.",
|
||||
"LABEL_LANGUAGE": "Lingua Applicazione",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Scegli la lingua preferita per l'estensione",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Scegli la lingua dell'estensione",
|
||||
"LABEL_TROUBLESHOOTING": "Risoluzione Problemi",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Strumenti per risolvere problemi di connessione",
|
||||
"BTN_REGEN_ID": "Rigenera ID Peer",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Rigenera il tuo ID interno e riconnettiti",
|
||||
"REGEN_ID_DESC": "Usa questo se riscontri errori di \"Identità Duplicata\".",
|
||||
"REGEN_ID_OTHER_ISSUE": "Altro problema? Apri una Issue su GitHub",
|
||||
"BTN_REGEN_ID": "Rigenera ID Utente",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Genera un nuovo ID interno e riconnettiti",
|
||||
"REGEN_ID_DESC": "Usa questa opzione se riscontri errori di 'Identità Duplicata'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Hai altri problemi? Apri una segnalazione su GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identità rigenerata — riconnessione…",
|
||||
"LABEL_CONN_STATUS": "Stato Connessione",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Stato corrente della connessione WebSocket",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Stato attuale della connessione",
|
||||
"CONN_STATUS_DISCONNECTED": "Disconnesso",
|
||||
"BTN_RETRY": "RIPROVA",
|
||||
"BTN_RETRY_TOOLTIP": "Tenta di riconnettersi al server",
|
||||
"BTN_COPY_LOGS": "Copia Log",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copia i log negli appunti per la condivisione",
|
||||
"LABEL_VIDEO_DEBUG": "Info Debug Video",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Dettagli tecnici sull'elemento video attualmente selezionato",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copia i log negli appunti",
|
||||
"LABEL_VIDEO_DEBUG": "Debug Video",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Dettagli tecnici sul video selezionato",
|
||||
"VIDEO_DEBUG_EMPTY": "Nessuna scheda selezionata o video rilevato.",
|
||||
"LABEL_HISTORY": "Cronologia Azioni Completa",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registro cronologico di tutti i comandi di sincronizzazione nella stanza",
|
||||
"LABEL_HISTORY": "Cronologia Completa",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registro di tutti i comandi inviati nella stanza",
|
||||
"HISTORY_EMPTY": "Ancora nessuna attività",
|
||||
"LABEL_LOGS": "Log (Ultimi 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Log di connessione tecnica per il debug",
|
||||
"LABEL_LOGS_TOOLTIP": "Log tecnici per il debug",
|
||||
"BTN_CLEAR": "PULISCI",
|
||||
"BTN_CLEAR_TOOLTIP": "Cancella l'output del log",
|
||||
"BTN_CLEAR_TOOLTIP": "Cancella i log",
|
||||
"LABEL_GITHUB": "Repository GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Salta",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Salta il tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Avanti",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Vai al passaggio successivo",
|
||||
"ONBOARDING_1_TITLE": "Benvenuto in KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Guarda i video insieme in perfetta sincronia, non importa dove ti trovi. Facciamo un rapido tour!",
|
||||
"ONBOARDING_1_TEXT": "Guarda i video insieme ai tuoi amici in perfetta sincronia. Facciamo un breve tour!",
|
||||
"ONBOARDING_2_TITLE": "1. Crea una Stanza",
|
||||
"ONBOARDING_2_TEXT": "Inizia qui. Crea una stanza e condividi il link di invito con i tuoi amici.",
|
||||
"ONBOARDING_2_TEXT": "Inizia da qui. Crea una stanza e condividi il link con i tuoi amici.",
|
||||
"ONBOARDING_3_TITLE": "2. Seleziona Video",
|
||||
"ONBOARDING_3_TEXT": "Naviga qui per selezionare il video che vuoi sincronizzare. Riproduci, metti in pausa e cerca: tutti rimangono sincronizzati.",
|
||||
"ONBOARDING_3_TEXT": "Scegli il video che vuoi sincronizzare. Play, pausa o salta: tutti vedranno lo stesso.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizza",
|
||||
"ONBOARDING_4_TEXT": "Scegli un nome utente divertente così i tuoi amici sanno chi sei.",
|
||||
"ONBOARDING_4_TEXT": "Scegli un nome utente per farti riconoscere dai tuoi amici.",
|
||||
"ONBOARDING_5_TITLE": "Tutto pronto!",
|
||||
"ONBOARDING_5_TEXT": "È ora di prendere i popcorn. Buona visione insieme!",
|
||||
"ONBOARDING_5_TEXT": "È ora di prendere i popcorn. Buona visione!",
|
||||
"ERR_CONN_TIMEOUT": "Connessione scaduta. Riprova.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato URL del server non valido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento e riprova.",
|
||||
"ERR_NO_PEERS_TIME": "Nessun altro peer con un orario noto. Passa a 'Salto da Me'.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento.",
|
||||
"ERR_NO_PEERS_TIME": "Nessun altro partecipante con posizione nota. Passa a 'Portali alla mia posizione'.",
|
||||
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
|
||||
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
|
||||
"TOAST_INVITE_COPIED": "Link di invito copiato!",
|
||||
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
|
||||
"TOAST_LOBBY_SKIPPED": "Lobby dell'episodio saltata.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Impossibile saltare la lobby.",
|
||||
"TOAST_LOBBY_SKIPPED": "Sala d'attesa saltata.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Impossibile saltare la sala d'attesa.",
|
||||
"TOAST_LOGS_COPIED": "Copiato!",
|
||||
"TOAST_PEER_JOINED": "{name} è entrato nella stanza",
|
||||
"TOAST_PEER_LEFT": "{name} ha lasciato la stanza",
|
||||
"TOAST_PEER_ACTION": "{name} ha {action}",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Connesso",
|
||||
"STATUS_RECONNECTING": "Riconnessione...",
|
||||
"STATUS_CONNECTING": "Connessione in corso...",
|
||||
"STATUS_FAILED": "Fallito",
|
||||
"STATUS_FAILED": "Errore",
|
||||
"STATUS_DISCONNECTED": "Disconnesso",
|
||||
"STATUS_IDLE": "Pronto a connettersi",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync è pronto. Entra o crea una stanza per connetterti e avviare la sincronizzazione.",
|
||||
"BTN_STATE_JOINING": "🚀 Entrando...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Riconnessione...",
|
||||
"BTN_STATE_PLAYING": "▶ Riproduzione...",
|
||||
"BTN_STATE_PLAYING": "▶ In riproduzione...",
|
||||
"BTN_STATE_PAUSING": "⏸ In pausa...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione al gruppo ({time})...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione di gruppo ({time})...",
|
||||
"BTN_STATE_SYNCING": "Sincronizzazione...",
|
||||
"BTN_STATE_SYNCED": "✅ Sincronizzato!",
|
||||
"NOTIF_PLAY": "avviato la riproduzione",
|
||||
"NOTIF_PAUSE": "messo in pausa la riproduzione",
|
||||
"NOTIF_SEEK": "cercato nel video",
|
||||
"NOTIF_FORCE_PREPARE": "avviato la sincronizzazione forzata",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizzato tutti",
|
||||
"DEBUG_NO_TAB": "Nessuna scheda di destinazione selezionata.",
|
||||
"DEBUG_COMM_FAIL": "Impossibile comunicare con il video della scheda.",
|
||||
"EMPTY_PEERS_TITLE": "Ancora nessun partecipante",
|
||||
"EMPTY_PEERS_HINT": "Condividi il tuo link di invito per iniziare",
|
||||
"EMPTY_HISTORY_TITLE": "Ancora nessuna attività",
|
||||
"EMPTY_HISTORY_HINT": "Riproduci, metti in pausa o cerca per vedere la cronologia",
|
||||
"NOTIF_PLAY": "ha avviato la riproduzione",
|
||||
"NOTIF_PAUSE": "ha messo in pausa",
|
||||
"NOTIF_SEEK": "ha cambiato posizione",
|
||||
"NOTIF_FORCE_PREPARE": "ha avviato una sincronizzazione forzata",
|
||||
"NOTIF_FORCE_EXECUTE": "ha sincronizzato tutti",
|
||||
"DEBUG_NO_TAB": "Nessuna scheda selezionata.",
|
||||
"DEBUG_COMM_FAIL": "Errore di comunicazione con il video.",
|
||||
"EMPTY_PEERS_TITLE": "Nessun partecipante",
|
||||
"EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare",
|
||||
"EMPTY_HISTORY_TITLE": "Nessuna attività",
|
||||
"EMPTY_HISTORY_HINT": "Usa i comandi per vedere la cronologia",
|
||||
"EMPTY_LOGS_TITLE": "Nessun log",
|
||||
"EMPTY_LOGS_HINT": "Gli eventi di connessione appariranno qui",
|
||||
"EMPTY_LOGS_HINT": "Gli eventi appariranno qui",
|
||||
"EMPTY_ROOMS_TITLE": "Nessuna stanza attiva",
|
||||
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna per trovare quelle pubbliche",
|
||||
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna l'elenco",
|
||||
"LABEL_YOU": "Tu",
|
||||
"ONBOARDING_DONE": "Fatto!",
|
||||
"LABEL_LOBBY_PEER_READY": "Pronto",
|
||||
@@ -173,40 +194,49 @@
|
||||
"LABEL_PEERS_COUNT": "{count} partecipanti",
|
||||
"LABEL_CUSTOM_SERVER": "Server Personalizzato",
|
||||
"BTN_STATE_CREATING": "🚀 Creazione Stanza...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronizzazione Episodio Fallita",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync annullato: {reason}. Potrebbe essere necessaria una sincronizzazione manuale.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Timeout",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (ripristinato)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Tutti gli altri partecipanti hanno lasciato la stanza",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — non tutti i partecipanti hanno caricato l'episodio",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincro Episodio Fallita",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Sincro auto annullata: {reason}. Usa la sincronizzazione manuale.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Tempo scaduto",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo scaduto (ripristinato)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Tutti gli altri partecipanti sono usciti",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo scaduto: caricamento incompleto per alcuni partecipanti",
|
||||
"LOBBY_CANCEL_USER": "Annullato dall'utente",
|
||||
"NOTIF_ERROR_TITLE": "Errore KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Offrimi un caffè",
|
||||
"FOOTER_REVIEW": "★ Valuta",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Valutaci",
|
||||
"FOOTER_SUPPORT_PROMPT": "Ti piace KoalaSync? Lascia una recensione!",
|
||||
"LABEL_AUDIO_PROCESSING": "Elaborazione audio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti audio come la compressione alla riproduzione video",
|
||||
"LABEL_AUDIO_PROCESSING": "Elaborazione Audio",
|
||||
"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_AUDIO": "Novità: Elaborazione Audio — prova il compressore!",
|
||||
"AUDIO_BACK": "← Indietro",
|
||||
"AUDIO_PAGE_TITLE": "Impostazioni audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Elaborazione audio",
|
||||
"AUDIO_PAGE_TITLE": "Impostazioni Audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Elaborazione Audio",
|
||||
"AUDIO_COMPRESSOR": "Compressore",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Attivato",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Attivo",
|
||||
"AUDIO_PRESET": "Preimpostazione",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Consigliato",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Gamma dinamica",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Miglioramento vocale",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Gamma Dinamica",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Miglioramento Vocale",
|
||||
"AUDIO_PRESET_SMOOTH": "Morbido",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizzato",
|
||||
"AUDIO_PARAM_THRESHOLD": "Soglia",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_PARAM_THRESHOLD": "Soglia (Threshold)",
|
||||
"AUDIO_PARAM_KNEE": "Ginocchio (Knee)",
|
||||
"AUDIO_PARAM_RATIO": "Rapporto (Ratio)",
|
||||
"AUDIO_PARAM_ATTACK": "Attacco (Attack)",
|
||||
"AUDIO_PARAM_RELEASE": "Rilascio (Release)",
|
||||
"AUDIO_EQUALIZER": "Equalizzatore",
|
||||
"AUDIO_COMING_SOON": "Prossimamente",
|
||||
"BTN_RESTART_TOUR": "Riavvia tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Riavvia il tutorial iniziale",
|
||||
"HINT_SELECT_VIDEO": "Seleziona il tuo video qui!"
|
||||
"BTN_RESTART_TOUR": "Riavvia Tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Riavvia il tutorial introduttivo",
|
||||
"HINT_SELECT_VIDEO": "Scegli il tuo video qui!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Invia titolo completo",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Invia ID episodio",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Non inviare",
|
||||
"LABEL_PRIVACY_SETTINGS": "Impostazioni privacy",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Scegli quali titoli di scheda e contenuto multimediale vengono inviati alla stanza.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"BTN_JOIN_ROOM": "ルームに参加 / 作成",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "ルームに接続",
|
||||
"LABEL_PUBLIC_ROOMS": "公開ルーム",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "このサーバー上の公開ルーム의リスト",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "このサーバー上の公開ルームのリスト",
|
||||
"BTN_REFRESH": "更新",
|
||||
"BTN_REFRESH_TOOLTIP": "公開ルームのリストを更新",
|
||||
"PUBLIC_ROOMS_REFRESHING": "更新中...",
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "友達とこのリンクを共有して参加してもらいましょう",
|
||||
"LABEL_PEERS_IN_ROOM": "ルーム内のメンバー",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "現在このルームに接続している他のユーザー",
|
||||
"LABEL_HOST_CONTROL": "ホスト操作",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "このルームで再生を操作できる人を制限します",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "再生を操作できるのは自分だけ",
|
||||
"NOTICE_HOST_CONTROLS": "ホストが全員の再生を操作します。",
|
||||
"NOTICE_COHOST_HINT": "ヒント: 下の参加者リストで個別の参加者に操作権限を付与できます。",
|
||||
"BADGE_HOST": "ホスト",
|
||||
"BADGE_GUEST": "ゲスト",
|
||||
"BADGE_CONTROLLER": "操作権あり",
|
||||
"BTN_GIVE_CONTROL": "操作権を付与",
|
||||
"BTN_REVOKE_CONTROL": "取り消す",
|
||||
"BADGE_DESYNCED": "ソロ",
|
||||
"TOOLTIP_PEER_DESYNCED": "単独で視聴中 — ホストのコマンドを無視しています",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · ホストがこのルームを操作中",
|
||||
"HCM_DIALOG_BODY": "このルームではホストのみが再生を操作できます。一緒に視聴を続けますか、それとも自分だけで視聴しますか?",
|
||||
"HCM_DIALOG_STAY": "同期を維持",
|
||||
"HCM_DIALOG_SOLO": "自分だけで視聴",
|
||||
"HCM_BADGE_SOLO": "自分だけで視聴中",
|
||||
"HCM_BADGE_RESYNC": "再同期",
|
||||
"NO_PEERS_CONNECTED": "接続しているメンバーはいません",
|
||||
"BTN_LEAVE_ROOM": "ルームを退室",
|
||||
"LABEL_SELECT_VIDEO": "ビデオを選択",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "内部IDを再生成して再接続します",
|
||||
"REGEN_ID_DESC": "「Duplicate Identity」(ID重複)エラーが表示される場合に使用します。",
|
||||
"REGEN_ID_OTHER_ISSUE": "他の問題?GitHub Issueを開く",
|
||||
"TOAST_ID_REGENERATED": "IDを再生成しました — 再接続中…",
|
||||
"LABEL_CONN_STATUS": "接続状態",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "現在のWebSocket接続状態",
|
||||
"CONN_STATUS_DISCONNECTED": "切断されました",
|
||||
@@ -143,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "接続中...",
|
||||
"STATUS_FAILED": "失敗",
|
||||
"STATUS_DISCONNECTED": "切断されました",
|
||||
"STATUS_IDLE": "接続準備完了",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSyncの準備ができました。ルームに参加するか作成して接続し、同期を開始してください。",
|
||||
"BTN_STATE_JOINING": "🚀 参加中...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 再接続中...",
|
||||
"BTN_STATE_PLAYING": "▶ 再生中...",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "タイムアウト — 一部のメンバーがエピソードを読み込めませんでした",
|
||||
"LOBBY_CANCEL_USER": "ユーザーによってキャンセルされました",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSyncエラー",
|
||||
"FOOTER_SUPPORT": "☕ コーヒーをおごってね",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ 評価する",
|
||||
"FOOTER_SUPPORT_PROMPT": "KoalaSyncはいかが?レビューを書いてください!",
|
||||
"LABEL_AUDIO_PROCESSING": "オーディオ処理",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "近日公開",
|
||||
"BTN_RESTART_TOUR": "チュートリアルを再起動",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "オンボーディングチュートリアルを再起動する",
|
||||
"HINT_SELECT_VIDEO": "ここで動画を選択してください!"
|
||||
"HINT_SELECT_VIDEO": "ここで動画を選択してください!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "完全なタイトルを送信",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "エピソードIDを送信",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "送信しない",
|
||||
"LABEL_PRIVACY_SETTINGS": "プライバシー設定",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "ルームに送信するタブタイトルとメディアタイトルを選択します。",
|
||||
"LABEL_SEND_TAB_TITLE": "タブタイトルを送信",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "選択中のブラウザータブのタイトルをルームに送信します。",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "メディアタイトル",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "メディアタイトルをルームに送信する方法を選択します。"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "방에 연결",
|
||||
"LABEL_PUBLIC_ROOMS": "공개 방",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "이 서버의 공개 방 목록",
|
||||
"BTN_REFRESH": "Refreschi",
|
||||
"BTN_REFRESH": "새로고침",
|
||||
"BTN_REFRESH_TOOLTIP": "공개 방 목록 새로고침",
|
||||
"PUBLIC_ROOMS_REFRESHING": "새로고침 중...",
|
||||
"BTN_REFRESH_COOLDOWN": "{seconds}초 대기",
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "친구들이 참여할 수 있도록 이 링크를 공유하세요",
|
||||
"LABEL_PEERS_IN_ROOM": "방의 참여자",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "현재 이 방에 연결되어 있는 다른 사용자들",
|
||||
"LABEL_HOST_CONTROL": "호스트 제어",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "이 방에서 재생을 제어할 수 있는 사람 제한",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "나만 재생을 제어할 수 있음",
|
||||
"NOTICE_HOST_CONTROLS": "호스트가 모두의 재생을 제어합니다.",
|
||||
"NOTICE_COHOST_HINT": "팁: 아래 참가자 목록에서 개별 참가자에게 제어 권한을 부여하세요.",
|
||||
"BADGE_HOST": "호스트",
|
||||
"BADGE_GUEST": "게스트",
|
||||
"BADGE_CONTROLLER": "제어자",
|
||||
"BTN_GIVE_CONTROL": "제어 권한 부여",
|
||||
"BTN_REVOKE_CONTROL": "취소",
|
||||
"BADGE_DESYNCED": "단독",
|
||||
"TOOLTIP_PEER_DESYNCED": "혼자 시청 중 — 호스트 명령을 무시합니다",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · 호스트가 이 방을 제어합니다",
|
||||
"HCM_DIALOG_BODY": "이 방에서는 호스트만 재생을 제어할 수 있습니다. 계속 함께 보시겠어요, 아니면 혼자 보시겠어요?",
|
||||
"HCM_DIALOG_STAY": "동기화 유지",
|
||||
"HCM_DIALOG_SOLO": "혼자 보기",
|
||||
"HCM_BADGE_SOLO": "혼자 시청 중",
|
||||
"HCM_BADGE_RESYNC": "다시 동기화",
|
||||
"NO_PEERS_CONNECTED": "연결된 참여자가 없습니다",
|
||||
"BTN_LEAVE_ROOM": "방 나가기",
|
||||
"LABEL_SELECT_VIDEO": "비디오 선택",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "내부 ID를 재생성하고 다시 연결합니다",
|
||||
"REGEN_ID_DESC": "\"중복된 ID\" 오류가 발생할 경우에 사용하세요.",
|
||||
"REGEN_ID_OTHER_ISSUE": "다른 문제? GitHub Issue 열기",
|
||||
"TOAST_ID_REGENERATED": "ID가 재생성되었습니다 — 다시 연결 중…",
|
||||
"LABEL_CONN_STATUS": "연결 상태",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "현재 WebSocket 연결 상태",
|
||||
"CONN_STATUS_DISCONNECTED": "연결 끊김",
|
||||
@@ -140,9 +159,11 @@
|
||||
"TOAST_PEER_ACTION": "{name} 님이 {action}",
|
||||
"STATUS_CONNECTED": "연결됨",
|
||||
"STATUS_RECONNECTING": "재연결 중...",
|
||||
"STATUS_CONNECTING": "연kel 중...",
|
||||
"STATUS_CONNECTING": "연결 중...",
|
||||
"STATUS_FAILED": "실패",
|
||||
"STATUS_DISCONNECTED": "연결 끊김",
|
||||
"STATUS_IDLE": "연결 준비 완료",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync가 준비되었습니다. 방에 참여하거나 생성하여 연결하고 동기화를 시작하세요.",
|
||||
"BTN_STATE_JOINING": "🚀 참여 중...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 재연결 중...",
|
||||
"BTN_STATE_PLAYING": "▶ 재생 중...",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "시간 초과 — 모든 피어가 에피소드를 로드하지 못했습니다",
|
||||
"LOBBY_CANCEL_USER": "사용자에 의해 취소됨",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync 오류",
|
||||
"FOOTER_SUPPORT": "☕ 커피 한 잔 사주세요",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ 평가하기",
|
||||
"FOOTER_SUPPORT_PROMPT": "KoalaSync가 마음에 드세요? 리뷰를 남겨주세요!",
|
||||
"LABEL_AUDIO_PROCESSING": "오디오 처리",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "출시 예정",
|
||||
"BTN_RESTART_TOUR": "튜토리얼 다시 시작",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "온보딩 튜토리얼을 다시 시작합니다",
|
||||
"HINT_SELECT_VIDEO": "여기에서 비디오를 선택하세요!"
|
||||
"HINT_SELECT_VIDEO": "여기에서 비디오를 선택하세요!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "전체 제목 보내기",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "에피소드 ID 보내기",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "보내지 않음",
|
||||
"LABEL_PRIVACY_SETTINGS": "개인정보 설정",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "방에 보낼 탭 제목과 미디어 제목을 선택합니다.",
|
||||
"LABEL_SEND_TAB_TITLE": "탭 제목 보내기",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "선택한 브라우저 탭 제목을 방에 보냅니다.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "미디어 제목",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "미디어 제목을 방에 보내는 방식을 선택합니다."
|
||||
}
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Deel deze link met vrienden zodat ze kunnen deelnemen",
|
||||
"LABEL_PEERS_IN_ROOM": "Deelnemers in kamer",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere gebruikers die momenteel zijn verbonden met deze kamer",
|
||||
"LABEL_HOST_CONTROL": "Hostbeheer",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Bepaal wie de weergave in deze kamer mag bedienen",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Alleen ik mag de weergave bedienen",
|
||||
"NOTICE_HOST_CONTROLS": "De host bedient de weergave voor iedereen.",
|
||||
"NOTICE_COHOST_HINT": "Tip: geef afzonderlijke deelnemers de besturing in de lijst hieronder.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Gast",
|
||||
"BADGE_CONTROLLER": "Bediener",
|
||||
"BTN_GIVE_CONTROL": "Bediening geven",
|
||||
"BTN_REVOKE_CONTROL": "Intrekken",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Zelfstandig aan het kijken — negeert de commando's van de host",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · De host bedient deze kamer",
|
||||
"HCM_DIALOG_BODY": "Alleen de host kan de weergave in deze kamer bedienen. Samen blijven kijken of zelf verder kijken?",
|
||||
"HCM_DIALOG_STAY": "Gesynchroniseerd blijven",
|
||||
"HCM_DIALOG_SOLO": "Zelf verder kijken",
|
||||
"HCM_BADGE_SOLO": "Je kijkt zelfstandig",
|
||||
"HCM_BADGE_RESYNC": "Opnieuw sync",
|
||||
"NO_PEERS_CONNECTED": "Geen deelnemers verbonden",
|
||||
"BTN_LEAVE_ROOM": "Kamer verlaten",
|
||||
"LABEL_SELECT_VIDEO": "Selecteer video",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Genereer uw interne ID opnieuw en maak opnieuw verbinding",
|
||||
"REGEN_ID_DESC": "Gebruik dit als u fouten ziet over 'Duplicate Identity'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Ander probleem? Open een GitHub Issue",
|
||||
"TOAST_ID_REGENERATED": "Identiteit opnieuw gegenereerd — opnieuw verbinden…",
|
||||
"LABEL_CONN_STATUS": "Verbindingsstatus",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Huidige WebSocket-verbindingsstatus",
|
||||
"CONN_STATUS_DISCONNECTED": "Verbinding verbroken",
|
||||
@@ -143,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "Verbinden...",
|
||||
"STATUS_FAILED": "Mislukt",
|
||||
"STATUS_DISCONNECTED": "Verbinding verbroken",
|
||||
"STATUS_IDLE": "Gereed om te verbinden",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync is gereed. Neem deel aan een kamer of maak er een aan om te verbinden en te synchroniseren.",
|
||||
"BTN_STATE_JOINING": "🚀 Deelnemen...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Opnieuw verbinden...",
|
||||
"BTN_STATE_PLAYING": "▶ Afspelen...",
|
||||
@@ -156,7 +177,7 @@
|
||||
"NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart",
|
||||
"NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd",
|
||||
"DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.",
|
||||
"DEBUG_COMM_FAIL": "Kon niet cmmuniceren met de videotab.",
|
||||
"DEBUG_COMM_FAIL": "Kon niet communiceren met de videotab.",
|
||||
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
|
||||
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
|
||||
"EMPTY_HISTORY_TITLE": "Nog geen activiteit",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Time-out — niet alle deelnemers hebben de aflevering geladen",
|
||||
"LOBBY_CANCEL_USER": "Geannuleerd door gebruiker",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync-fout",
|
||||
"FOOTER_SUPPORT": "☕ Trakteer op een koffie",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Beoordelen",
|
||||
"FOOTER_SUPPORT_PROMPT": "Vind je KoalaSync leuk? Laat een review achter!",
|
||||
"LABEL_AUDIO_PROCESSING": "Audioverwerking",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Binnenkort beschikbaar",
|
||||
"BTN_RESTART_TOUR": "Tutorial opnieuw starten",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Start de introductie-tutorial opnieuw",
|
||||
"HINT_SELECT_VIDEO": "Selecteer hier je video!"
|
||||
"HINT_SELECT_VIDEO": "Selecteer hier je video!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Volledige titel verzenden",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Afleverings-ID verzenden",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Niet verzenden",
|
||||
"LABEL_PRIVACY_SETTINGS": "Privacy-instellingen",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Kies welke tab- en mediatitels naar de kamer worden gestuurd.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Udostępnij ten link znajomym, aby mogli dołączyć",
|
||||
"LABEL_PEERS_IN_ROOM": "Uczestnicy w pokoju",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Inni użytkownicy aktualnie połączeni z tym pokojem",
|
||||
"LABEL_HOST_CONTROL": "Kontrola hosta",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Określ, kto może sterować odtwarzaniem w tym pokoju",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Tylko ja mogę sterować odtwarzaniem",
|
||||
"NOTICE_HOST_CONTROLS": "Host steruje odtwarzaniem dla wszystkich.",
|
||||
"NOTICE_COHOST_HINT": "Wskazówka: przyznaj sterowanie wybranym uczestnikom na liście poniżej.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Gość",
|
||||
"BADGE_CONTROLLER": "Sterujący",
|
||||
"BTN_GIVE_CONTROL": "Daj sterowanie",
|
||||
"BTN_REVOKE_CONTROL": "Odbierz",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Ogląda samodzielnie — ignoruje polecenia hosta",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Host steruje tym pokojem",
|
||||
"HCM_DIALOG_BODY": "Tylko host może sterować odtwarzaniem w tym pokoju. Oglądać dalej razem czy oglądać samodzielnie?",
|
||||
"HCM_DIALOG_STAY": "Pozostań zsynchronizowany",
|
||||
"HCM_DIALOG_SOLO": "Oglądaj samodzielnie",
|
||||
"HCM_BADGE_SOLO": "Oglądasz samodzielnie",
|
||||
"HCM_BADGE_RESYNC": "Zsynchronizuj",
|
||||
"NO_PEERS_CONNECTED": "Brak połączonych uczestników",
|
||||
"BTN_LEAVE_ROOM": "Opuść pokój",
|
||||
"LABEL_SELECT_VIDEO": "Wybierz wideo",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Wygeneruj ponownie swój identyfikator i połącz się ponownie",
|
||||
"REGEN_ID_DESC": "Użyj tego, jeśli widzisz błędy o zduplikowanej tożsamości.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Inny problem? Otwórz Issue na GitHub",
|
||||
"TOAST_ID_REGENERATED": "Tożsamość wygenerowana ponownie — ponowne łączenie…",
|
||||
"LABEL_CONN_STATUS": "Status połączenia",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Bieżący stan połączenia WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Rozłączono",
|
||||
@@ -143,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "Łączenie...",
|
||||
"STATUS_FAILED": "Nieudane",
|
||||
"STATUS_DISCONNECTED": "Rozłączono",
|
||||
"STATUS_IDLE": "Gotowy do połączenia",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync jest gotowy. Dołącz do pokoju lub utwórz go, aby się połączyć i rozpocząć synchronizację.",
|
||||
"BTN_STATE_JOINING": "🚀 Dołączanie...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Ponowne łączenie...",
|
||||
"BTN_STATE_PLAYING": "▶ Odtwarzanie...",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Limit czasu — nie wszyscy uczestnicy załadowali odcinek",
|
||||
"LOBBY_CANCEL_USER": "Anulowane przez użytkownika",
|
||||
"NOTIF_ERROR_TITLE": "Błąd KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Postaw kawę",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Oceń",
|
||||
"FOOTER_SUPPORT_PROMPT": "Podoba Ci się KoalaSync? Zostaw recenzję!",
|
||||
"LABEL_AUDIO_PROCESSING": "Przetwarzanie dźwięku",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Wkrótce",
|
||||
"BTN_RESTART_TOUR": "Uruchom samouczek ponownie",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Uruchom ponownie samouczek powitalny",
|
||||
"HINT_SELECT_VIDEO": "Wybierz swoje wideo tutaj!"
|
||||
"HINT_SELECT_VIDEO": "Wybierz swoje wideo tutaj!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Wysyłaj pełny tytuł",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Wysyłaj ID odcinka",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Nie wysyłaj",
|
||||
"LABEL_PRIVACY_SETTINGS": "Ustawienia prywatności",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Wybierz, które tytuły kart i multimediów są wysyłane do pokoju.",
|
||||
"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."
|
||||
}
|
||||
|
||||
+103
-73
@@ -4,9 +4,9 @@
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Sala",
|
||||
"TAB_ROOM_TOOLTIP": "Configurações da sala e conexão",
|
||||
"TAB_SYNC": "Sincro",
|
||||
"TAB_SYNC": "Sincronia",
|
||||
"TAB_SYNC_TOOLTIP": "Controles de sincronia de vídeo e ações",
|
||||
"TAB_SETTINGS": "Opções",
|
||||
"TAB_SETTINGS": "Configurações",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferências da extensão",
|
||||
"TAB_STATUS": "Status",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnósticos avançados e logs",
|
||||
@@ -15,23 +15,23 @@
|
||||
"LABEL_SERVER": "Servidor",
|
||||
"BTN_SERVER_OFFICIAL": "Oficial",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar o servidor oficial confiável",
|
||||
"BTN_SERVER_CUSTOM": "Perso",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectar ao seu próprio servidor auto-hospedado",
|
||||
"BTN_SERVER_CUSTOM": "Personalizado",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectar ao seu próprio servidor privado",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://seu-servidor:3000",
|
||||
"LABEL_ROOM_ID": "ID da sala",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para sua sala de sincronização",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo da sua sala",
|
||||
"PLACEHOLDER_ROOM_ID": "Digite o ID da sala",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala na qual você deseja entrar",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID da sala na qual você deseja entrar",
|
||||
"LABEL_PASSWORD": "Senha (Opcional)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Senha opcional para restringir o acesso à sala",
|
||||
"PLACEHOLDER_PASSWORD": "Senha da sala (opcional)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Senha para a sala (deixe em branco se não houver)",
|
||||
"PLACEHOLDER_PASSWORD": "Senha da sala",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Deixe em branco se não houver senha",
|
||||
"BTN_JOIN_ROOM": "Entrar / Criar sala",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Conectar à sala",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Entrar na sala",
|
||||
"LABEL_PUBLIC_ROOMS": "Salas públicas",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponíveis neste servidor",
|
||||
"BTN_REFRESH": "ATUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
|
||||
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Atualizando...",
|
||||
"BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A lista de salas está em espera. Tente novamente em {seconds}s.",
|
||||
@@ -41,99 +41,118 @@
|
||||
"ACTIVE_ROOM_NONE": "NENHUMA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Servidor oficial",
|
||||
"LABEL_INVITE_LINK": "Link de convite",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Compartilhe este link com amigos para que eles entrem",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes na sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros usuários conectados atualmente a esta sala",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Compartilhe este link com seus amigos para que eles entrem",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros usuários conectados a esta sala",
|
||||
"LABEL_HOST_CONTROL": "Controle do anfitrião",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Restringir quem pode controlar a reprodução nesta sala",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Somente eu posso controlar a reprodução",
|
||||
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
|
||||
"NOTICE_COHOST_HINT": "Dica: conceda o controle a participantes específicos na lista abaixo.",
|
||||
"BADGE_HOST": "Anfitrião",
|
||||
"BADGE_GUEST": "Convidado",
|
||||
"BADGE_CONTROLLER": "Controlador",
|
||||
"BTN_GIVE_CONTROL": "Dar controle",
|
||||
"BTN_REVOKE_CONTROL": "Revogar",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Assistindo por conta própria — ignora os comandos do anfitrião",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · O anfitrião controla esta sala",
|
||||
"HCM_DIALOG_BODY": "Somente o anfitrião pode controlar a reprodução nesta sala. Continuar assistindo juntos ou assistir por conta própria?",
|
||||
"HCM_DIALOG_STAY": "Manter sincronizado",
|
||||
"HCM_DIALOG_SOLO": "Assistir por conta própria",
|
||||
"HCM_BADGE_SOLO": "Assistindo por conta própria",
|
||||
"HCM_BADGE_RESYNC": "Ressincronizar",
|
||||
"NO_PEERS_CONNECTED": "Sem participantes conectados",
|
||||
"BTN_LEAVE_ROOM": "Sair da sala",
|
||||
"LABEL_SELECT_VIDEO": "Selecionar vídeo",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolha a aba do navegador contendo o vídeo a ser sincronizado",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolha a aba do navegador com o vídeo a ser sincronizado",
|
||||
"OPTION_SELECT_TAB": "-- Selecione uma aba --",
|
||||
"LABEL_REMOTE_CONTROL": "Controle remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link de convite",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar link de convite",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Enviar um comando de reproduzir para todos",
|
||||
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
|
||||
"BTN_PAUSE": "⏸ Pausar",
|
||||
"BTN_PAUSE_TOOLTIP": "Enviar um comando de pausar para todos",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
|
||||
"BTN_SYNC": "⚡ SINCRO",
|
||||
"BTN_SYNC_TOOLTIP": "Forçar todos os usuários a sincronizar",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para os outros",
|
||||
"OPTION_JUMP_TO_ME": "Trazer para mim",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para a posição dos outros",
|
||||
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Escolher o alvo de sincronização",
|
||||
"LABEL_LAST_ACTIVITY": "Última atividade",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausar ou buscar",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausar ou pular",
|
||||
"NO_RECENT_COMMANDS": "Sem comandos recentes",
|
||||
"LOBBY_HEADER": "LOBBY DE EPISÓDIO",
|
||||
"LOBBY_HEADER": "SALA DE ESPERA",
|
||||
"LOBBY_WAITING_FOR": "🎬 Aguardando: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Aguardando participantes...",
|
||||
"BTN_SKIP_PLAY": "Pular e reproduzir",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar o lobby e reproduzir assim mesmo",
|
||||
"BTN_SKIP_PLAY": "Pular e reproduzir agora",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar a espera e reproduzir mesmo assim",
|
||||
"LOBBY_CONNECT_FIRST": "Entre em uma sala primeiro",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Você precisa entrar em uma sala através de um link de convite ou criar uma nova para sincronizar vídeos.",
|
||||
"BTN_CREATE_ROOM_ALT": "Criar nova sala",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Criar uma nova sala aleatória e entrar",
|
||||
"LABEL_USERNAME": "Seu nome de usuário",
|
||||
"LABEL_USERNAME_TOOLTIP": "O nome de usuário ajuda os outros a identificá-lo.",
|
||||
"LABEL_USERNAME_TOOLTIP": "Seu nome visível para os outros.",
|
||||
"PLACEHOLDER_USERNAME": "Coala anônimo",
|
||||
"LABEL_HIDE_CLUTTER": "Lista de abas limpa",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra abas que não são de vídeo e domínios não relacionados para manter a lista organizada",
|
||||
"LABEL_HIDE_CLUTTER": "Ocultar abas sem vídeo",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra abas não relacionadas para manter a lista limpa",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sincro auto do próximo episódio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda todos ao mudar de episódio, depois inicia em sincronia.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Auto-copiar link",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link de convite para a área de transferência ao criar uma nova sala.",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda todos ao mudar de episódio.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Auto-copiar link de convite",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link para a área de transferência ao criar uma nova sala.",
|
||||
"LABEL_NOTIFICATIONS": "Notificações do navegador",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notificações do sistema quando alguém entra/sai ou reproduz/pausa.",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notificações quando alguém entra, sai, reproduz ou pausa.",
|
||||
"LABEL_LANGUAGE": "Idioma do aplicativo",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Escolha o idioma de sua preferência para a extensão",
|
||||
"LABEL_TROUBLESHOOTING": "Resolução de problemas",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para solucionar problemas de conexão",
|
||||
"BTN_REGEN_ID": "Regenerar ID de participante",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerar seu ID interno e conectar novamente",
|
||||
"REGEN_ID_DESC": "Use esta opção se notar erros de 'Identidade duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um Issue no GitHub",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Escolha o seu idioma preferido",
|
||||
"LABEL_TROUBLESHOOTING": "Solução de problemas",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para corrigir problemas de conexão",
|
||||
"BTN_REGEN_ID": "Regenerar ID do usuário",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Gerar um novo ID interno e conectar novamente",
|
||||
"REGEN_ID_DESC": "Use esta opção se aparecer o erro de 'Identidade duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um relatório no GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identidade regenerada — reconectando…",
|
||||
"LABEL_CONN_STATUS": "Status da conexão",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Status atual da conexão WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desconectado",
|
||||
"BTN_RETRY": "REENTRAR",
|
||||
"BTN_RETRY": "TENTAR NOVAMENTE",
|
||||
"BTN_RETRY_TOOLTIP": "Tentar reconectar ao servidor",
|
||||
"BTN_COPY_LOGS": "Copiar logs",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copiar logs para a área de transferência para compartilhar",
|
||||
"LABEL_VIDEO_DEBUG": "Informações de depuração de vídeo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o elemento de vídeo selecionado atualmente",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copiar logs para a área de transferência",
|
||||
"LABEL_VIDEO_DEBUG": "Debug de vídeo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o vídeo selecionado no momento",
|
||||
"VIDEO_DEBUG_EMPTY": "Nenhuma aba selecionada ou vídeo detectado.",
|
||||
"LABEL_HISTORY": "Histórico completo",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registro cronológico de todos os comandos de sincronização na sala",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registro cronológico de todos os comandos na sala",
|
||||
"HISTORY_EMPTY": "Sem atividade ainda",
|
||||
"LABEL_LOGS": "Logs (Últimos 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Logs técnicos de conexão para depuração",
|
||||
"BTN_CLEAR": "LIMPAR",
|
||||
"BTN_CLEAR_TOOLTIP": "Limpar a saída dos logs",
|
||||
"BTN_CLEAR_TOOLTIP": "Limpar os logs",
|
||||
"LABEL_GITHUB": "Repositório GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Pular",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Pular o tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Próximo",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir para o próximo passo",
|
||||
"ONBOARDING_1_TITLE": "Bem-vindo ao KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Assista a vídeos juntos em perfeita sincronia, não importa onde estejam. Vamos fazer um tour rápido!",
|
||||
"ONBOARDING_1_TEXT": "Assista a vídeos com seus amigos em perfeita sincronia, não importa onde estejam. Vamos fazer um tour rápido!",
|
||||
"ONBOARDING_2_TITLE": "1. Criar uma sala",
|
||||
"ONBOARDING_2_TEXT": "Comece aqui. Crie uma sala e compartilhe o link de convite com seus amigos.",
|
||||
"ONBOARDING_3_TITLE": "2. Selecionar vídeo",
|
||||
"ONBOARDING_3_TEXT": "Navegue aqui para selecionar o vídeo a ser sincronizado. Reproduza, pause e busque: todos permanecem sincronizados.",
|
||||
"ONBOARDING_3_TEXT": "Navegue aqui para selecionar o vídeo a ser sincronizado. Reproduza, pause e avance — todos verão o mesmo.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizar",
|
||||
"ONBOARDING_4_TEXT": "Escolha um nome de usuário divertido para que seus amigos saibam quem você é.",
|
||||
"ONBOARDING_4_TEXT": "Escolha um nome de usuário divertido para que seus amigos reconheçam você.",
|
||||
"ONBOARDING_5_TITLE": "Tudo pronto!",
|
||||
"ONBOARDING_5_TEXT": "Hora de pegar a pipoca. Divirta-se assistindo com seus amigos!",
|
||||
"ERR_CONN_TIMEOUT": "Tempo limite de conexão esgotado. Tente novamente.",
|
||||
"ERR_CONN_TIMEOUT": "Tempo limite esgotado. Tente novamente.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato de URL do servidor inválido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento e tente novamente.",
|
||||
"ERR_NO_PEERS_TIME": "Nenhum outro participante com posição conhecida. Mude para 'Traerlos a mí' / 'Trazer para mim'.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento.",
|
||||
"ERR_NO_PEERS_TIME": "Nenhum participante com posição conhecida. Mude para 'Trazer para a minha posição'.",
|
||||
"ERR_NO_VIDEO_TAB": "Não foi possível conectar à aba do vídeo.",
|
||||
"ERR_SELECT_VIDEO": "Selecione um vídeo primeiro!",
|
||||
"TOAST_INVITE_COPIED": "Link de convite copiado!",
|
||||
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
|
||||
"TOAST_LOBBY_SKIPPED": "Lobby de episódio ignorado.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar o lobby.",
|
||||
"TOAST_LOBBY_SKIPPED": "Sala de espera ignorada.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar a sala de espera.",
|
||||
"TOAST_LOGS_COPIED": "Copiado!",
|
||||
"TOAST_PEER_JOINED": "{name} entrou na sala",
|
||||
"TOAST_PEER_LEFT": "{name} saiu da sala",
|
||||
@@ -143,49 +162,51 @@
|
||||
"STATUS_CONNECTING": "Conectando...",
|
||||
"STATUS_FAILED": "Falhou",
|
||||
"STATUS_DISCONNECTED": "Desconectado",
|
||||
"STATUS_IDLE": "Pronto para conectar",
|
||||
"STATUS_IDLE_TOOLTIP": "O KoalaSync está pronto. Entre ou crie uma sala para conectar e começar a sincronizar.",
|
||||
"BTN_STATE_JOINING": "🚀 Entrando...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
|
||||
"BTN_STATE_PLAYING": "▶ Reproduzindo...",
|
||||
"BTN_STATE_PAUSING": "⏸ Pausando...",
|
||||
"BTN_STATE_PAUSING": "⏸ Em pausa...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizando com o grupo ({time})...",
|
||||
"BTN_STATE_SYNCING": "Sincronizando...",
|
||||
"BTN_STATE_SYNCED": "✅ Sincronizado!",
|
||||
"NOTIF_PLAY": "iniciou a reprodução",
|
||||
"NOTIF_PAUSE": "pausou a reprodução",
|
||||
"NOTIF_PAUSE": "pausou o vídeo",
|
||||
"NOTIF_SEEK": "mudou a posição do vídeo",
|
||||
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
|
||||
"DEBUG_NO_TAB": "Nenhuma aba alvo selecionada.",
|
||||
"DEBUG_COMM_FAIL": "Não foi possível comunicar com o vídeo da aba.",
|
||||
"EMPTY_PEERS_TITLE": "Nenhum participante ainda",
|
||||
"DEBUG_NO_TAB": "Nenhuma aba selecionada.",
|
||||
"DEBUG_COMM_FAIL": "Erro ao se comunicar com o vídeo.",
|
||||
"EMPTY_PEERS_TITLE": "Nenhum participante",
|
||||
"EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar",
|
||||
"EMPTY_HISTORY_TITLE": "Sem atividade ainda",
|
||||
"EMPTY_HISTORY_HINT": "Reproduza, pause ou busque para ver o histórico",
|
||||
"EMPTY_HISTORY_TITLE": "Sem atividade",
|
||||
"EMPTY_HISTORY_HINT": "Reproduza ou pause para ver o histórico",
|
||||
"EMPTY_LOGS_TITLE": "Sem logs",
|
||||
"EMPTY_LOGS_HINT": "Os eventos de conexão aparecerão aqui",
|
||||
"EMPTY_ROOMS_TITLE": "Sem salas ativas",
|
||||
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize para buscar salas públicas",
|
||||
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize a lista",
|
||||
"LABEL_YOU": "Você",
|
||||
"ONBOARDING_DONE": "Concluído!",
|
||||
"LABEL_LOBBY_PEER_READY": "Pronto",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Carregando...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Protegido por senha",
|
||||
"LABEL_PEERS_COUNT": "{count} participantes",
|
||||
"LABEL_CUSTOM_SERVER": "Servidor personalizado",
|
||||
"LABEL_CUSTOM_SERVER": "Servidor privado",
|
||||
"BTN_STATE_CREATING": "🚀 Criando sala...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Falha na sincronização do episódio",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Pode ser necessário sincronizar manualmente.",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Você deve sincronizar manualmente.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Tempo limite esgotado",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo limite esgotado (recuperado)",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo limite (recuperado)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo limite esgotado — nem todos os participantes carregaram o episódio",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo limite — nem todos carregaram o episódio",
|
||||
"LOBBY_CANCEL_USER": "Cancelado pelo usuário",
|
||||
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Me pague um café",
|
||||
"NOTIF_ERROR_TITLE": "Erro no KoalaSync",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Avaliar",
|
||||
"FOOTER_SUPPORT_PROMPT": "Gostou do KoalaSync? Deixe uma avaliação!",
|
||||
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio como compressão à reprodução de vídeo",
|
||||
"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!",
|
||||
"AUDIO_BACK": "← Voltar",
|
||||
@@ -196,17 +217,26 @@
|
||||
"AUDIO_PRESET": "Predefinição",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Faixa dinâmica",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Aprimoramento de voz",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Aprimoramento vocal",
|
||||
"AUDIO_PRESET_SMOOTH": "Suave",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizado",
|
||||
"AUDIO_PARAM_THRESHOLD": "Limiar",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_PARAM_THRESHOLD": "Limiar (Threshold)",
|
||||
"AUDIO_PARAM_KNEE": "Joelho (Knee)",
|
||||
"AUDIO_PARAM_RATIO": "Razão (Ratio)",
|
||||
"AUDIO_PARAM_ATTACK": "Ataque (Attack)",
|
||||
"AUDIO_PARAM_RELEASE": "Liberação (Release)",
|
||||
"AUDIO_EQUALIZER": "Equalizador",
|
||||
"AUDIO_COMING_SOON": "Em breve",
|
||||
"BTN_RESTART_TOUR": "Reiniciar tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial de introdução",
|
||||
"HINT_SELECT_VIDEO": "Selecione seu vídeo aqui!"
|
||||
"HINT_SELECT_VIDEO": "Selecione seu vídeo aqui!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Enviar título completo",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Enviar ID do episódio",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Não enviar",
|
||||
"LABEL_PRIVACY_SETTINGS": "Configurações de privacidade",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Escolha quais títulos de aba e mídia são enviados para a sala.",
|
||||
"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."
|
||||
}
|
||||
|
||||
+131
-101
@@ -4,136 +4,155 @@
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Sala",
|
||||
"TAB_ROOM_TOOLTIP": "Definições da sala e ligação",
|
||||
"TAB_SYNC": "Sincronização",
|
||||
"TAB_SYNC": "Sincronia",
|
||||
"TAB_SYNC_TOOLTIP": "Controlo de sincronização de vídeo e ações remotas",
|
||||
"TAB_SETTINGS": "Definições",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferências da extensão",
|
||||
"TAB_STATUS": "Estado",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnóstico avanzado e registos",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnóstico avançado e registos",
|
||||
"BTN_CREATE_ROOM": "+ Criar Nova Sala",
|
||||
"MANUAL_CONNECT_HEADER": "Ligação Manual / Avançado",
|
||||
"LABEL_SERVER": "Servidor",
|
||||
"BTN_SERVER_OFFICIAL": "Oficial",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar o servidor oficial fiável",
|
||||
"BTN_SERVER_CUSTOM": "Personalizado",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Ligar ao seu próprio servidor auto-hospedado",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Ligar ao seu próprio servidor privado",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://o-seu-servidor:3000",
|
||||
"LABEL_ROOM_ID": "ID da Sala",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para a sua sala de sincronização",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para a sua sala",
|
||||
"PLACEHOLDER_ROOM_ID": "Introduzir ID da Sala",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala à qual deseja aderir",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID da sala à qual deseja aceder",
|
||||
"LABEL_PASSWORD": "Palavra-passe (Opcional)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Palavra-passe opcional para restringir o acesso à sala",
|
||||
"PLACEHOLDER_PASSWORD": "Palavra-passe da sala (opcional)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Palavra-passe para la sala (deixar em branco se não houver)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Palavra-passe para restringir o acesso à sala",
|
||||
"PLACEHOLDER_PASSWORD": "Palavra-passe da sala",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Deixe em branco se não houver palavra-passe",
|
||||
"BTN_JOIN_ROOM": "Entrar / Criar Sala",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Ligar à sala",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Entrar na sala",
|
||||
"LABEL_PUBLIC_ROOMS": "Salas Públicas",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponíveis neste servidor",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas neste servidor",
|
||||
"BTN_REFRESH": "ATUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
|
||||
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "A atualizar...",
|
||||
"BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A atualização da lista de salas está em espera. Tente novamente em {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "A atualizar salas públicas. Próxima atualização disponível em {seconds}s.",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A atualização está em espera. Tente novamente em {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "A atualizar salas. Próxima atualização em {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Sala Ativa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual está atualmente ligado",
|
||||
"ACTIVE_ROOM_NONE": "NENHUMA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Servidor Oficial",
|
||||
"LABEL_INVITE_LINK": "Link de Invito",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Partilhar este link com os amigos para que possam entrar",
|
||||
"LABEL_INVITE_LINK": "Link de Convite",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Partilhe este link com os amigos para que possam entrar",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes na Sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros utilizadores attualmente ligados a questa sala",
|
||||
"NO_PEERS_CONNECTED": "Nenhum partecipante ligado",
|
||||
"BTN_LEAVE_ROOM": "Sair della Sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Utilizadores atualmente ligados a esta sala",
|
||||
"LABEL_HOST_CONTROL": "Controlo do anfitrião",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Restringir quem pode controlar a reprodução nesta sala",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Só eu posso controlar a reprodução",
|
||||
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
|
||||
"NOTICE_COHOST_HINT": "Dica: conceda o controlo a participantes específicos na lista abaixo.",
|
||||
"BADGE_HOST": "Anfitrião",
|
||||
"BADGE_GUEST": "Convidado",
|
||||
"BADGE_CONTROLLER": "Controlador",
|
||||
"BTN_GIVE_CONTROL": "Dar controlo",
|
||||
"BTN_REVOKE_CONTROL": "Revogar",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "A ver por conta própria — ignora os comandos do anfitrião",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · O anfitrião controla esta sala",
|
||||
"HCM_DIALOG_BODY": "Só o anfitrião pode controlar a reprodução nesta sala. Continuar a ver em conjunto ou ver por conta própria?",
|
||||
"HCM_DIALOG_STAY": "Manter sincronizado",
|
||||
"HCM_DIALOG_SOLO": "Ver por minha conta",
|
||||
"HCM_BADGE_SOLO": "A ver por tua conta",
|
||||
"HCM_BADGE_RESYNC": "Ressincronizar",
|
||||
"NO_PEERS_CONNECTED": "Nenhum participante ligado",
|
||||
"BTN_LEAVE_ROOM": "Sair da Sala",
|
||||
"LABEL_SELECT_VIDEO": "Selecionar Vídeo",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolher o separador do navegador que contém o vídeo a sincronizar",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolha o separador do navegador com o vídeo a sincronizar",
|
||||
"OPTION_SELECT_TAB": "-- Selecionar um Separador --",
|
||||
"LABEL_REMOTE_CONTROL": "Controlo Remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link de Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Invito",
|
||||
"BTN_COPY_INVITE": "📋 Link de Convite",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Convite",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Enviar um comando de Reproduzir para todos",
|
||||
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Enviar um comando de Pausa para todos",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forçar todos os utilizadores a sincronizarem-se",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para os Outros",
|
||||
"OPTION_JUMP_TO_ME": "Ir para Mim",
|
||||
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
|
||||
"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",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Escolher o destino de sincronização",
|
||||
"LABEL_LAST_ACTIVITY": "Estado da Última Atividade",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausa ou busca",
|
||||
"LABEL_LAST_ACTIVITY": "Última Atividade",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente",
|
||||
"NO_RECENT_COMMANDS": "Nenhum comando recente",
|
||||
"LOBBY_HEADER": "LOBBY DE EPISÓDIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 A aguardar: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "A aguardar participantes...",
|
||||
"BTN_SKIP_PLAY": "Ignorar e reproduzir mesmo assim",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar o lobby e reproduzir mesmo assim",
|
||||
"LOBBY_CONNECT_FIRST": "Ligar-se primeiro a uma sala",
|
||||
"LOBBY_HEADER": "SALA DE ESPERA",
|
||||
"LOBBY_WAITING_FOR": "🎬 À espera de: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "À espera dos participantes...",
|
||||
"BTN_SKIP_PLAY": "Ignorar e reproduzir agora",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar a espera e reproduzir mesmo assim",
|
||||
"LOBBY_CONNECT_FIRST": "Ligue-se primeiro a uma sala",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Precisa de entrar numa sala através de um link de convite ou criar uma nova para sincronizar vídeos.",
|
||||
"BTN_CREATE_ROOM_ALT": "Criar Nova Sala",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Criar uma nova sala aleatória e entrar nela",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Criar uma sala aleatória e entrar nela",
|
||||
"LABEL_USERNAME": "O seu Nome de Utilizador",
|
||||
"LABEL_USERNAME_TOOLTIP": "O nome de utilizador ajuda os outros a identificá-lo.",
|
||||
"LABEL_USERNAME_TOOLTIP": "O seu nome visível para os outros.",
|
||||
"PLACEHOLDER_USERNAME": "Coala Anónimo",
|
||||
"LABEL_HIDE_CLUTTER": "Ocultar Separadores Desnecessários",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra separadores sem vídeo e domínios não relacionados para manter a lista limpa",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sincronização Automática do Próximo Episódio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda por todos os participantes quando um episódio muda, iniciando depois a reprodução em sincronia.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Copiar Automaticamente o Link de Convite",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link de convite para a área de transferência ao criar uma nova sala.",
|
||||
"LABEL_HIDE_CLUTTER": "Ocultar separadores sem vídeo",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra separadores não relacionados para manter a lista limpa",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sincro Auto do Próximo Episódio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda por todos ao mudar de episódio.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Copiar Convite Automaticamente",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link para a área de transferência ao criar uma nova sala.",
|
||||
"LABEL_NOTIFICATIONS": "Notificações do Navegador",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notificações do sistema quando alguém entra/sai ou reproduz/pausa.",
|
||||
"LABEL_LANGUAGE": "Idioma da App",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Escolha o seu idioma de preferência para a extensão",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notificações quando alguém entra, sai, reproduz ou pausa.",
|
||||
"LABEL_LANGUAGE": "Idioma da Aplicação",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Escolha o seu idioma de preferência",
|
||||
"LABEL_TROUBLESHOOTING": "Resolução de Problemas",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para corrigir problemas de ligação",
|
||||
"BTN_REGEN_ID": "Regerar ID do Peer",
|
||||
"BTN_REGEN_ID": "Regerar ID do Utilizador",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regerar o seu ID interno e voltar a ligar",
|
||||
"REGEN_ID_DESC": "Use isto se vir erros de \"Identidade Duplicata\".",
|
||||
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um Issue no GitHub",
|
||||
"REGEN_ID_DESC": "Use isto se encontrar erros de 'Identidade Duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um relatório no GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identidade regenerada — a voltar a ligar…",
|
||||
"LABEL_CONN_STATUS": "Estado da Ligação",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação WebSocket",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação",
|
||||
"CONN_STATUS_DISCONNECTED": "Desligado",
|
||||
"BTN_RETRY": "TENTAR NOVAMENTE",
|
||||
"BTN_RETRY_TOOLTIP": "Tentar voltar a ligar ao servidor",
|
||||
"BTN_COPY_LOGS": "Copiar Registos",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copiar registos para a área de transferência para partilha",
|
||||
"LABEL_VIDEO_DEBUG": "Info de Debug de Vídeo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o elemento de vídeo atualmente seleccionado",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copiar registos para a área de transferência",
|
||||
"LABEL_VIDEO_DEBUG": "Debug de Vídeo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o vídeo atualmente selecionado",
|
||||
"VIDEO_DEBUG_EMPTY": "Nenhum separador selecionado ou vídeo detetado.",
|
||||
"LABEL_HISTORY": "Histórico Completo de Ações",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registo cronológico de todos os comandos de sincronização na sala",
|
||||
"LABEL_HISTORY": "Histórico Completo",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registo cronológico de todos os comandos na sala",
|
||||
"HISTORY_EMPTY": "Nenhuma atividade ainda",
|
||||
"LABEL_LOGS": "Registos (Últimos 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Registos de ligação técnica para depuração",
|
||||
"LABEL_LOGS_TOOLTIP": "Registos técnicos para depuração",
|
||||
"BTN_CLEAR": "LIMPAR",
|
||||
"BTN_CLEAR_TOOLTIP": "Limpar a saída do registo",
|
||||
"BTN_CLEAR_TOOLTIP": "Limpar os registos",
|
||||
"LABEL_GITHUB": "Repositório GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Saltar",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Saltar o tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Seguinte",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir para o passo seguinte",
|
||||
"ONBOARDING_1_TITLE": "Bem-vindo ao KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Veja vídeos em conjunto numa sincronizzazione perfetta — não importa onde esteja. Vamos fare uma breve visita guiada!",
|
||||
"ONBOARDING_1_TEXT": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Vamos fazer uma breve visita guiada!",
|
||||
"ONBOARDING_2_TITLE": "1. Criar uma Sala",
|
||||
"ONBOARDING_2_TEXT": "Comece aqui. Crie uma sala e partilhe o link de convite com os seus amigos.",
|
||||
"ONBOARDING_3_TITLE": "2. Selecionar Vídeo",
|
||||
"ONBOARDING_3_TEXT": "Navegue até aqui para selecionar o vídeo que deseja sincronizar. Reproduza, pause e avance — todos ficam em sincronia.",
|
||||
"ONBOARDING_3_TEXT": "Navegue até aqui para selecionar o vídeo a sincronizar. Reproduza, pause e avance — todos vêm o mesmo.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizar",
|
||||
"ONBOARDING_4_TEXT": "Escolha um nome de utilizador divertido para que os seus amigos saibam quem você é.",
|
||||
"ONBOARDING_4_TEXT": "Escolha um nome de utilizador divertido para que os seus amigos o reconheçam.",
|
||||
"ONBOARDING_5_TITLE": "Está tudo pronto!",
|
||||
"ONBOARDING_5_TEXT": "Altura de ir buscar pipocas. Divirtam-se a ver juntos!",
|
||||
"ONBOARDING_5_TEXT": "Hora de ir buscar as pipocas. Divirtam-se!",
|
||||
"ERR_CONN_TIMEOUT": "Ligação expirada. Tente novamente.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato de URL do Servidor inválido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento e tente novamente.",
|
||||
"ERR_NO_PEERS_TIME": "Nenhum outro peer com hora conhecida. Mude para 'Ir para Mim'.",
|
||||
"ERR_NO_VIDEO_TAB": "Não foi possível ligar ao separador do vídeo.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato de URL do servidor inválido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento.",
|
||||
"ERR_NO_PEERS_TIME": "Nenhum participante com hora conhecida. Mude para 'Trazer para a minha posição'.",
|
||||
"ERR_NO_VIDEO_TAB": "Não foi possível aceder ao separador do vídeo.",
|
||||
"ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!",
|
||||
"TOAST_INVITE_COPIED": "Link de convite copiado!",
|
||||
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
|
||||
"TOAST_LOBBY_SKIPPED": "Lobby do episódio ignorado.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar o lobby.",
|
||||
"TOAST_LOBBY_SKIPPED": "Sala de espera ignorada.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar a sala de espera.",
|
||||
"TOAST_LOGS_COPIED": "Copiado!",
|
||||
"TOAST_PEER_JOINED": "{name} entrou na sala",
|
||||
"TOAST_PEER_LEFT": "{name} saiu da sala",
|
||||
@@ -143,70 +162,81 @@
|
||||
"STATUS_CONNECTING": "A ligar...",
|
||||
"STATUS_FAILED": "Falhou",
|
||||
"STATUS_DISCONNECTED": "Desligado",
|
||||
"STATUS_IDLE": "Pronto para ligar",
|
||||
"STATUS_IDLE_TOOLTIP": "O KoalaSync está pronto. Entre ou crie uma sala para ligar e iniciar a sincronização.",
|
||||
"BTN_STATE_JOINING": "🚀 A entrar...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 A voltar a ligar...",
|
||||
"BTN_STATE_PLAYING": "▶ A reproduzir...",
|
||||
"BTN_STATE_PAUSING": "⏸ A pausar...",
|
||||
"BTN_STATE_SYNCING_GROUP": "A sincronizar com o grupo ({time})....",
|
||||
"BTN_STATE_PAUSING": "⏸ Em pausa...",
|
||||
"BTN_STATE_SYNCING_GROUP": "A sincronizar em grupo ({time})....",
|
||||
"BTN_STATE_SYNCING": "A sincronizar...",
|
||||
"BTN_STATE_SYNCED": "✅ Sincronizado!",
|
||||
"NOTIF_PLAY": "iniciou a reprodução",
|
||||
"NOTIF_PAUSE": "pausou a reprodução",
|
||||
"NOTIF_SEEK": "avançou/recuou no vídeo",
|
||||
"NOTIF_FORCE_PREPARE": "iniciou a sincronização forçada",
|
||||
"NOTIF_PAUSE": "pausou o vídeo",
|
||||
"NOTIF_SEEK": "mudou a posição do vídeo",
|
||||
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
|
||||
"DEBUG_NO_TAB": "Nenhum separador de destino selecionado.",
|
||||
"DEBUG_COMM_FAIL": "Não foi possível comunicar com o vídeo do separador.",
|
||||
"EMPTY_PEERS_TITLE": "Nenhum participante ainda",
|
||||
"DEBUG_NO_TAB": "Nenhum separador selecionado.",
|
||||
"DEBUG_COMM_FAIL": "Erro ao comunicar com o vídeo.",
|
||||
"EMPTY_PEERS_TITLE": "Nenhum participante",
|
||||
"EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar",
|
||||
"EMPTY_HISTORY_TITLE": "Nenhuma atividade ainda",
|
||||
"EMPTY_HISTORY_HINT": "Reproduza, pause ou avance para ver o histórico",
|
||||
"EMPTY_LOGS_TITLE": "Nenhum registo",
|
||||
"EMPTY_LOGS_HINT": "Os eventi de ligação vão aparecer aqui",
|
||||
"EMPTY_HISTORY_TITLE": "Nenhuma atividade",
|
||||
"EMPTY_HISTORY_HINT": "Reproduza ou pause para ver o histórico",
|
||||
"EMPTY_LOGS_TITLE": "Sem registos",
|
||||
"EMPTY_LOGS_HINT": "Os eventos de ligação vão aparecer aqui",
|
||||
"EMPTY_ROOMS_TITLE": "Nenhuma sala ativa",
|
||||
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize para encontrar salas públicas",
|
||||
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize a lista",
|
||||
"LABEL_YOU": "Tu",
|
||||
"ONBOARDING_DONE": "Concluído!",
|
||||
"LABEL_LOBBY_PEER_READY": "Pronto",
|
||||
"LABEL_LOBBY_PEER_LOADING": "A carregar...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Protegido por Palavra-passe",
|
||||
"LABEL_PEERS_COUNT": "{count} participantes",
|
||||
"LABEL_CUSTOM_SERVER": "Servidor Personalizado",
|
||||
"LABEL_CUSTOM_SERVER": "Servidor Privado",
|
||||
"BTN_STATE_CREATING": "🚀 A Criar Sala...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronização do Episódio Falhou",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Falha na Sincronização do Episódio",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Pode ter de sincronizar manualmente.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Timeout",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (recuperado)",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Tempo limite expirado",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo limite (recuperado)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — nem todos os participantes carregaram o episódio",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo limite — alguns participantes não carregaram o episódio",
|
||||
"LOBBY_CANCEL_USER": "Cancelado pelo utilizador",
|
||||
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Oferece um café",
|
||||
"NOTIF_ERROR_TITLE": "Erro no KoalaSync",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Avaliar",
|
||||
"FOOTER_SUPPORT_PROMPT": "Gostas do KoalaSync? Deixa uma avaliação!",
|
||||
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio como compressão à reprodução de vídeo",
|
||||
"LABEL_AUDIO_PROCESSING": "Processamento de Áudio",
|
||||
"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!",
|
||||
"AUDIO_BACK": "← Voltar",
|
||||
"AUDIO_PAGE_TITLE": "Configurações de áudio",
|
||||
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
|
||||
"AUDIO_PAGE_TITLE": "Configurações de Áudio",
|
||||
"AUDIO_MASTER_TOGGLE": "Processamento de Áudio",
|
||||
"AUDIO_COMPRESSOR": "Compressor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Ativado",
|
||||
"AUDIO_PRESET": "Predefinição",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Gama dinâmica",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Melhoria vocal",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Gama Dinâmica",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Melhoria Vocal",
|
||||
"AUDIO_PRESET_SMOOTH": "Suave",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizado",
|
||||
"AUDIO_PARAM_THRESHOLD": "Limiar",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_PARAM_THRESHOLD": "Limiar (Threshold)",
|
||||
"AUDIO_PARAM_KNEE": "Joelho (Knee)",
|
||||
"AUDIO_PARAM_RATIO": "Rácio (Ratio)",
|
||||
"AUDIO_PARAM_ATTACK": "Ataque (Attack)",
|
||||
"AUDIO_PARAM_RELEASE": "Libertação (Release)",
|
||||
"AUDIO_EQUALIZER": "Equalizador",
|
||||
"AUDIO_COMING_SOON": "Em breve",
|
||||
"BTN_RESTART_TOUR": "Reiniciar tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial de introdução",
|
||||
"HINT_SELECT_VIDEO": "Selecione o seu vídeo aqui!"
|
||||
"BTN_RESTART_TOUR": "Reiniciar Tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial introdutório",
|
||||
"HINT_SELECT_VIDEO": "Selecione o seu vídeo aqui!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Enviar título completo",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Enviar ID do episódio",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Não enviar",
|
||||
"LABEL_PRIVACY_SETTINGS": "Definições de privacidade",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Escolhe que títulos do separador e do conteúdo multimédia são enviados para a sala.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Поделитесь этой ссылкой с друзьями, чтобы они вошли",
|
||||
"LABEL_PEERS_IN_ROOM": "Участники в комнате",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Другие пользователи, подключенные к этой комнате",
|
||||
"LABEL_HOST_CONTROL": "Управление ведущим",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Ограничить, кто может управлять воспроизведением в этой комнате",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Только я могу управлять воспроизведением",
|
||||
"NOTICE_HOST_CONTROLS": "Ведущий управляет воспроизведением для всех.",
|
||||
"NOTICE_COHOST_HINT": "Совет: предоставьте управление отдельным участникам в списке ниже.",
|
||||
"BADGE_HOST": "Ведущий",
|
||||
"BADGE_GUEST": "Гость",
|
||||
"BADGE_CONTROLLER": "Управляющий",
|
||||
"BTN_GIVE_CONTROL": "Дать управление",
|
||||
"BTN_REVOKE_CONTROL": "Отозвать",
|
||||
"BADGE_DESYNCED": "Соло",
|
||||
"TOOLTIP_PEER_DESYNCED": "Смотрит отдельно — игнорирует команды ведущего",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Комнатой управляет ведущий",
|
||||
"HCM_DIALOG_BODY": "Управлять воспроизведением в этой комнате может только ведущий. Продолжить смотреть вместе или смотреть самостоятельно?",
|
||||
"HCM_DIALOG_STAY": "Оставаться синхронно",
|
||||
"HCM_DIALOG_SOLO": "Смотреть самостоятельно",
|
||||
"HCM_BADGE_SOLO": "Вы смотрите самостоятельно",
|
||||
"HCM_BADGE_RESYNC": "Синхронизировать",
|
||||
"NO_PEERS_CONNECTED": "Нет подключенных участников",
|
||||
"BTN_LEAVE_ROOM": "Выйти из комнаты",
|
||||
"LABEL_SELECT_VIDEO": "Выбрать видео",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Сбросить ваш внутренний идентификатор и переподключиться",
|
||||
"REGEN_ID_DESC": "Используйте при появлении ошибок дублирования идентификации.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Другая проблема? Откройте Issue на GitHub",
|
||||
"TOAST_ID_REGENERATED": "Идентичность перегенерирована — переподключение…",
|
||||
"LABEL_CONN_STATUS": "Статус подключения",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Текущий статус WebSocket-соединения с сервером",
|
||||
"CONN_STATUS_DISCONNECTED": "Отключено",
|
||||
@@ -143,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "Подключение...",
|
||||
"STATUS_FAILED": "Ошибка",
|
||||
"STATUS_DISCONNECTED": "Отключено",
|
||||
"STATUS_IDLE": "Готов к подключению",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync готов. Присоединитесь к комнате или создайте её, чтобы подключиться и начать синхронизацию.",
|
||||
"BTN_STATE_JOINING": "🚀 Вход...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Переподключение...",
|
||||
"BTN_STATE_PLAYING": "▶ Запуск...",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
|
||||
"LOBBY_CANCEL_USER": "Отменено пользователем",
|
||||
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Угости кофе",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Оценить",
|
||||
"FOOTER_SUPPORT_PROMPT": "Нравится KoalaSync? Оставьте отзыв!",
|
||||
"LABEL_AUDIO_PROCESSING": "Обработка звука",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Скоро",
|
||||
"BTN_RESTART_TOUR": "Перезапустить обучение",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Запустить приветственное руководство заново",
|
||||
"HINT_SELECT_VIDEO": "Выберите ваше видео здесь!"
|
||||
"HINT_SELECT_VIDEO": "Выберите ваше видео здесь!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Отправлять полное название",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Отправлять ID эпизода",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Не отправлять",
|
||||
"LABEL_PRIVACY_SETTINGS": "Настройки приватности",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Выберите, какие названия вкладки и медиа отправляются в комнату.",
|
||||
"LABEL_SEND_TAB_TITLE": "Отправлять название вкладки",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Отправляет в комнату название выбранной вкладки браузера.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Название медиа",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Выберите, как названия медиа отправляются в комнату."
|
||||
}
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Katılabilmeleri için bu bağlantıyı arkadaşlarınızla paylaşın",
|
||||
"LABEL_PEERS_IN_ROOM": "Odadaki Kişiler",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Şu anda bu odaya bağlı olan diğer kullanıcılar",
|
||||
"LABEL_HOST_CONTROL": "Sunucu Kontrolü",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Bu odada oynatmayı kimin kontrol edebileceğini belirleyin",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Oynatmayı yalnızca ben kontrol edebilirim",
|
||||
"NOTICE_HOST_CONTROLS": "Oynatmayı herkes için sunucu kontrol eder.",
|
||||
"NOTICE_COHOST_HINT": "İpucu: aşağıdaki katılımcı listesinde tek tek katılımcılara kontrol verin.",
|
||||
"BADGE_HOST": "Sunucu",
|
||||
"BADGE_GUEST": "Misafir",
|
||||
"BADGE_CONTROLLER": "Kontrolcü",
|
||||
"BTN_GIVE_CONTROL": "Kontrol ver",
|
||||
"BTN_REVOKE_CONTROL": "Geri al",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Tek başına izliyor — sunucu komutlarını yok sayar",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Bu odayı sunucu kontrol ediyor",
|
||||
"HCM_DIALOG_BODY": "Bu odada oynatmayı yalnızca sunucu kontrol edebilir. Birlikte izlemeye devam mı, yoksa kendi başına mı izlemek istiyorsun?",
|
||||
"HCM_DIALOG_STAY": "Senkron kal",
|
||||
"HCM_DIALOG_SOLO": "Kendi başıma izle",
|
||||
"HCM_BADGE_SOLO": "Kendi başına izliyorsun",
|
||||
"HCM_BADGE_RESYNC": "Yeniden senkronize et",
|
||||
"NO_PEERS_CONNECTED": "Bağlı kimse yok",
|
||||
"BTN_LEAVE_ROOM": "Odadan Ayrıl",
|
||||
"LABEL_SELECT_VIDEO": "Video Seç",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Dahili kimliğinizi yeniden oluşturun ve tekrar bağlanın",
|
||||
"REGEN_ID_DESC": "\"Duplicate Identity\" (Mükerrer Kimlik) hataları görüyorsanız bunu kullanın.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Başka bir sorun? GitHub Issue açın",
|
||||
"TOAST_ID_REGENERATED": "Kimlik yeniden oluşturuldu — yeniden bağlanılıyor…",
|
||||
"LABEL_CONN_STATUS": "Bağlantı Durumu",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Mevcut WebSocket bağlantı durumu",
|
||||
"CONN_STATUS_DISCONNECTED": "Bağlantı Kesildi",
|
||||
@@ -143,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "Bağlanılıyor...",
|
||||
"STATUS_FAILED": "Başarısız",
|
||||
"STATUS_DISCONNECTED": "Bağlantı Kesildi",
|
||||
"STATUS_IDLE": "Bağlanmaya hazır",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync hazır. Bağlanmak ve senkronizasyonu başlatmak için bir odaya katılın veya oluşturun.",
|
||||
"BTN_STATE_JOINING": "🚀 Katılınıyor...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Yeniden bağlanılıyor...",
|
||||
"BTN_STATE_PLAYING": "▶ Oynatılıyor...",
|
||||
@@ -181,7 +202,7 @@
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zaman aşımı — tüm bağlantılar bölümü yüklemedi",
|
||||
"LOBBY_CANCEL_USER": "Kullanıcı tarafından iptal edildi",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync Hatası",
|
||||
"FOOTER_SUPPORT": "☕ Bana kahve ısmarla",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Değerlendir",
|
||||
"FOOTER_SUPPORT_PROMPT": "KoalaSync'i beğendin mi? Bir yorum bırak!",
|
||||
"LABEL_AUDIO_PROCESSING": "Ses işleme",
|
||||
@@ -208,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Çok yakında",
|
||||
"BTN_RESTART_TOUR": "Eğitimi Yeniden Başlat",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Tanıtım eğitimini yeniden başlat",
|
||||
"HINT_SELECT_VIDEO": "Videonuzu buradan seçin!"
|
||||
"HINT_SELECT_VIDEO": "Videonuzu buradan seçin!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Tam başlığı gönder",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Bölüm ID’sini gönder",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Gönderme",
|
||||
"LABEL_PRIVACY_SETTINGS": "Gizlilik ayarları",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Odaya hangi sekme ve medya başlıklarının gönderileceğini seçin.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"LANG_CODE": "uk",
|
||||
"HTML_CLASS": "lang-uk",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Кімната",
|
||||
"TAB_ROOM_TOOLTIP": "Налаштування кімнати та підключення",
|
||||
"TAB_SYNC": "Синхронізувати",
|
||||
"TAB_SYNC_TOOLTIP": "Елементи керування синхронізацією відео та віддалені дії",
|
||||
"TAB_SETTINGS": "Налаштування",
|
||||
"TAB_SETTINGS_TOOLTIP": "Параметри розширення",
|
||||
"TAB_STATUS": "Статус",
|
||||
"TAB_STATUS_TOOLTIP": "Розширена діагностика та журнали",
|
||||
"BTN_CREATE_ROOM": "+ Створити нову кімнату",
|
||||
"MANUAL_CONNECT_HEADER": "Ручне підключення / Додатково",
|
||||
"LABEL_SERVER": "Сервер",
|
||||
"BTN_SERVER_OFFICIAL": "Офіційний",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Використовуйте офіційний надійний сервер",
|
||||
"BTN_SERVER_CUSTOM": "Власний",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Підключіться до власного серверу, розміщеного на власному хостингу",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
|
||||
"LABEL_ROOM_ID": "ID кімнати",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "Унікальний ідентифікатор вашої кімнати синхронізації",
|
||||
"PLACEHOLDER_ROOM_ID": "Введіть Room ID",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Унікальний ідентифікатор кімнати, до якої ви хочете приєднатися",
|
||||
"LABEL_PASSWORD": "Пароль (необов'язково)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Додатковий пароль для обмеження доступу до кімнати",
|
||||
"PLACEHOLDER_PASSWORD": "Пароль кімнати (необов'язково)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Пароль для кімнати (залиште порожнім, якщо немає)",
|
||||
"BTN_JOIN_ROOM": "Приєднатися / Створити кімнату",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Підключіться до кімнати",
|
||||
"LABEL_PUBLIC_ROOMS": "Громадські кімнати",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Список загальнодоступних кімнат на цьому сервері",
|
||||
"BTN_REFRESH": "ОНОВИТИ",
|
||||
"BTN_REFRESH_TOOLTIP": "Оновити список громадських кімнат",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Оновлення...",
|
||||
"BTN_REFRESH_COOLDOWN": "ЧЕКАЙТЕ {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Оновлення списку кімнат охолоджується. Повторіть спробу в {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Оновлення громадських кімнат. Наступне оновлення доступне в {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Активна кімната",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Кімната, до якої ви зараз підключені",
|
||||
"ACTIVE_ROOM_NONE": "ЖОДНОГО",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Офіційний сервер",
|
||||
"LABEL_INVITE_LINK": "Посилання на запрошення",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Поділіться цим посиланням з друзями, щоб вони могли приєднатися",
|
||||
"LABEL_PEERS_IN_ROOM": "Учасники в кімнаті",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Інші користувачі зараз підключені до цієї кімнати",
|
||||
"LABEL_HOST_CONTROL": "Керування ведучим",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Обмежити, хто може керувати відтворенням у цій кімнаті",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Лише я можу керувати відтворенням",
|
||||
"NOTICE_HOST_CONTROLS": "Ведучий керує відтворенням для всіх.",
|
||||
"NOTICE_COHOST_HINT": "Порада: надайте керування окремим учасникам у списку нижче.",
|
||||
"BADGE_HOST": "Ведучий",
|
||||
"BADGE_GUEST": "Гість",
|
||||
"BADGE_CONTROLLER": "Керуючий",
|
||||
"BTN_GIVE_CONTROL": "Дати керування",
|
||||
"BTN_REVOKE_CONTROL": "Відкликати",
|
||||
"BADGE_DESYNCED": "Соло",
|
||||
"TOOLTIP_PEER_DESYNCED": "Дивиться окремо — ігнорує команди ведучого",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Кімнатою керує ведучий",
|
||||
"HCM_DIALOG_BODY": "Керувати відтворенням у цій кімнаті може лише ведучий. Продовжити дивитися разом чи дивитися самостійно?",
|
||||
"HCM_DIALOG_STAY": "Залишатися синхронно",
|
||||
"HCM_DIALOG_SOLO": "Дивитися самостійно",
|
||||
"HCM_BADGE_SOLO": "Ви дивитеся самостійно",
|
||||
"HCM_BADGE_RESYNC": "Синхронізувати",
|
||||
"NO_PEERS_CONNECTED": "Немає підключених однорангових пристроїв",
|
||||
"BTN_LEAVE_ROOM": "Вийти з кімнати",
|
||||
"LABEL_SELECT_VIDEO": "Виберіть Відео",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Виберіть вкладку браузера, яка містить відео для синхронізації",
|
||||
"OPTION_SELECT_TAB": "-- Виберіть вкладку --",
|
||||
"LABEL_REMOTE_CONTROL": "Пульт дистанційного керування",
|
||||
"BTN_COPY_INVITE": "📋 Посилання на запрошення",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Копіювати посилання для запрошення",
|
||||
"BTN_PLAY": "▶ Відтворити",
|
||||
"BTN_PLAY_TOOLTIP": "Надішліть усім команду Play",
|
||||
"BTN_PAUSE": "⏸ Пауза",
|
||||
"BTN_PAUSE_TOOLTIP": "Надішліть команду паузи всім",
|
||||
"BTN_SYNC": "⚡ СИНХР",
|
||||
"BTN_SYNC_TOOLTIP": "Змусити всіх користувачів синхронізуватися",
|
||||
"OPTION_JUMP_TO_OTHERS": "Перейти до інших",
|
||||
"OPTION_JUMP_TO_ME": "Перейти до мене",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Виберіть ціль синхронізації",
|
||||
"LABEL_LAST_ACTIVITY": "Статус останньої дії",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Показує останню команду відтворення, паузи або перемотування",
|
||||
"NO_RECENT_COMMANDS": "Немає останніх команд",
|
||||
"LOBBY_HEADER": "ЕПІЗОДНЕ ЛОБІ",
|
||||
"LOBBY_WAITING_FOR": "🎬 Чекаємо: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Очікування учасників...",
|
||||
"BTN_SKIP_PLAY": "Все одно пропустити та відтворити",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Скасуйте лобі та все одно відтворюйте",
|
||||
"LOBBY_CONNECT_FIRST": "Спочатку підключіться до кімнати",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Вам потрібно приєднатися до кімнати за посиланням із запрошенням або створити нову, щоб синхронізувати відео.",
|
||||
"BTN_CREATE_ROOM_ALT": "Створити нову кімнату",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Створіть нову випадкову кімнату та приєднайтеся до неї",
|
||||
"LABEL_USERNAME": "Ваше ім'я користувача",
|
||||
"LABEL_USERNAME_TOOLTIP": "Ім'я користувача допомагає іншим ідентифікувати вас.",
|
||||
"PLACEHOLDER_USERNAME": "Анонімна коала",
|
||||
"LABEL_HIDE_CLUTTER": "Приховати безладні вкладки",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Відфільтровує вкладки, не пов’язані з відео, і непов’язані домени, щоб зберегти список чистим",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Автоматична синхронізація наступного епізоду",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Автоматично призупиняється та чекає на всіх однорангових пристроїв, коли епізод зміниться, а потім синхронізація починається разом.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Автоматичне копіювання посилання для запрошення",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Автоматично копіює посилання запрошення в буфер обміну під час створення нової кімнати.",
|
||||
"LABEL_NOTIFICATIONS": "Сповіщення браузера",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Показує рідні системні сповіщення, коли хтось приєднується/виходить або відтворює/призупиняє.",
|
||||
"LABEL_LANGUAGE": "Мова програми",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Виберіть бажану мову розширення",
|
||||
"LABEL_TROUBLESHOOTING": "Усунення несправностей",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Інструменти для вирішення проблем з підключенням",
|
||||
"BTN_REGEN_ID": "Регенерувати Peer ID",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Повторно згенеруйте свій внутрішній ідентифікатор і повторно підключіться",
|
||||
"REGEN_ID_DESC": "Використовуйте це, якщо ви бачите помилку \"Дублікати ідентифікатора\".",
|
||||
"REGEN_ID_OTHER_ISSUE": "Інша проблема? Відкрийте випуск GitHub",
|
||||
"TOAST_ID_REGENERATED": "Ідентичність перегенеровано — повторне підключення…",
|
||||
"LABEL_CONN_STATUS": "Статус підключення",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Поточний стан підключення WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Відключено",
|
||||
"BTN_RETRY": "ПОВТОРИТИ",
|
||||
"BTN_RETRY_TOOLTIP": "Спробуйте повторно підключитися до сервера",
|
||||
"BTN_COPY_LOGS": "Копіювати журнали",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Скопіюйте журнали в буфер обміну для спільного використання",
|
||||
"LABEL_VIDEO_DEBUG": "Інформація про налагодження відео",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Технічні відомості про поточний вибраний елемент відео",
|
||||
"VIDEO_DEBUG_EMPTY": "Вкладка не вибрана або відео не виявлено.",
|
||||
"LABEL_HISTORY": "Повна історія дій",
|
||||
"LABEL_HISTORY_TOOLTIP": "Хронологічний журнал усіх команд синхронізації в кімнаті",
|
||||
"HISTORY_EMPTY": "Активності ще немає",
|
||||
"LABEL_LOGS": "Журнали (останні 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Журнали технічного підключення для налагодження",
|
||||
"BTN_CLEAR": "Очистити",
|
||||
"BTN_CLEAR_TOOLTIP": "Очистити журнал",
|
||||
"LABEL_GITHUB": "Репозиторій GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Пропустити",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Пропустити підручник",
|
||||
"BTN_ONBOARDING_NEXT": "Далі",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Перейти до наступного кроку",
|
||||
"ONBOARDING_1_TITLE": "Ласкаво просимо до KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Дивіться відео разом у ідеальній синхронізації — де б ви не були. Давайте зробимо короткий тур!",
|
||||
"ONBOARDING_2_TITLE": "1. Створіть кімнату",
|
||||
"ONBOARDING_2_TEXT": "Почніть тут. Створіть кімнату та поділіться посиланням запрошення з друзями.",
|
||||
"ONBOARDING_3_TITLE": "2. Виберіть Відео",
|
||||
"ONBOARDING_3_TEXT": "Перейдіть сюди, щоб вибрати відео, яке потрібно синхронізувати. Відтворення, пауза та пошук — усі залишаються синхронізованими.",
|
||||
"ONBOARDING_4_TITLE": "3. Персоналізація",
|
||||
"ONBOARDING_4_TEXT": "Виберіть цікаве ім’я користувача, щоб ваші друзі знали, хто ви.",
|
||||
"ONBOARDING_5_TITLE": "Усе готово!",
|
||||
"ONBOARDING_5_TEXT": "Час взяти попкорн. Приємного перегляду разом!",
|
||||
"ERR_CONN_TIMEOUT": "Час очікування підключення минув. Спробуйте ще раз.",
|
||||
"ERR_INVALID_SERVER_URL": "Недійсний формат URL-адреси сервера.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Посвідчення особи ще не завантажено. Зачекайте хвилинку та повторіть спробу.",
|
||||
"ERR_NO_PEERS_TIME": "Немає інших аналогів із відомим часом. Переключіться на «Перейти до мене».",
|
||||
"ERR_NO_VIDEO_TAB": "Не вдалося підключитися до вкладки відео.",
|
||||
"ERR_SELECT_VIDEO": "Спочатку виберіть відео!",
|
||||
"TOAST_INVITE_COPIED": "Посилання на запрошення скопійовано!",
|
||||
"TOAST_COPY_FAILED": "Не вдалося скопіювати в буфер обміну",
|
||||
"TOAST_LOBBY_SKIPPED": "Лобі епізоду пропущено.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Не вдалося пропустити лобі.",
|
||||
"TOAST_LOGS_COPIED": "Скопійовано!",
|
||||
"TOAST_PEER_JOINED": "{name} приєднався до кімнати",
|
||||
"TOAST_PEER_LEFT": "{name} вийшов з кімнати",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Підключено",
|
||||
"STATUS_RECONNECTING": "Повторне підключення...",
|
||||
"STATUS_CONNECTING": "Підключення...",
|
||||
"STATUS_FAILED": "Не вдалося",
|
||||
"STATUS_DISCONNECTED": "Відключено",
|
||||
"STATUS_IDLE": "Готовий до підключення",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync готовий. Приєднайтеся до кімнати або створіть її, щоб підключитися та почати синхронізацію.",
|
||||
"BTN_STATE_JOINING": "🚀 Приєднуюсь...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Повторне підключення...",
|
||||
"BTN_STATE_PLAYING": "▶ Грає...",
|
||||
"BTN_STATE_PAUSING": "⏸ Призупинення...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Синхронізація з групою ({time})...",
|
||||
"BTN_STATE_SYNCING": "Синхронізація...",
|
||||
"BTN_STATE_SYNCED": "✅ Синхронізовано!",
|
||||
"NOTIF_PLAY": "почав відтворення",
|
||||
"NOTIF_PAUSE": "призупинено відтворення",
|
||||
"NOTIF_SEEK": "перемотав відео",
|
||||
"NOTIF_FORCE_PREPARE": "почав примусову синхронізацію",
|
||||
"NOTIF_FORCE_EXECUTE": "синхронізував усіх",
|
||||
"DEBUG_NO_TAB": "Цільова вкладка не вибрана.",
|
||||
"DEBUG_COMM_FAIL": "Не вдалося зв’язатися з відео вкладки.",
|
||||
"EMPTY_PEERS_TITLE": "Учасників ще немає",
|
||||
"EMPTY_PEERS_HINT": "Поділіться своїм запрошенням, щоб почати",
|
||||
"EMPTY_HISTORY_TITLE": "Активності ще немає",
|
||||
"EMPTY_HISTORY_HINT": "Відтворення, призупинення або перемотування, щоб побачити історію",
|
||||
"EMPTY_LOGS_TITLE": "Без журналів",
|
||||
"EMPTY_LOGS_HINT": "Тут відображатимуться події підключення",
|
||||
"EMPTY_ROOMS_TITLE": "Немає активних кімнат",
|
||||
"EMPTY_ROOMS_HINT": "Створіть кімнату або оновіть, щоб знайти загальнодоступні",
|
||||
"LABEL_YOU": "Ви",
|
||||
"ONBOARDING_DONE": "Готово!",
|
||||
"LABEL_LOBBY_PEER_READY": "Готовий",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Завантаження...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Захищено паролем",
|
||||
"LABEL_PEERS_COUNT": "{count} учасників",
|
||||
"LABEL_CUSTOM_SERVER": "Спеціальний сервер",
|
||||
"BTN_STATE_CREATING": "🚀 Створення кімнати...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Помилка синхронізації епізоду",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Автоматичну синхронізацію скасовано: {reason}. Можливо, вам знадобиться виконати синхронізацію вручну.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Час очікування",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Тайм-аут (відновлено)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Усі інші учасники вийшли",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Тайм-аут — не всі вузли завантажили епізод",
|
||||
"LOBBY_CANCEL_USER": "Скасовано користувачем",
|
||||
"NOTIF_ERROR_TITLE": "Помилка KoalaSync",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Оцініть нас",
|
||||
"FOOTER_SUPPORT_PROMPT": "Подобається KoalaSync? Залиште відгук!",
|
||||
"LABEL_AUDIO_PROCESSING": "Обробка аудіо",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Застосуйте звукові ефекти, такі як стиснення, до відтворення відео",
|
||||
"AUDIO_OPEN_SETTINGS": "Відкрити",
|
||||
"NEW_FEATURE_AUDIO": "Новинка: обробка звуку — спробуйте компресор!",
|
||||
"AUDIO_BACK": "← Назад",
|
||||
"AUDIO_PAGE_TITLE": "Параметри звуку",
|
||||
"AUDIO_MASTER_TOGGLE": "Обробка аудіо",
|
||||
"AUDIO_COMPRESSOR": "Компресор",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Увімкнено",
|
||||
"AUDIO_PRESET": "Попереднє налаштування",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Рекомендовано",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Динамічний діапазон",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Покращення голосу",
|
||||
"AUDIO_PRESET_SMOOTH": "Плавний",
|
||||
"AUDIO_PRESET_CUSTOM": "Власний",
|
||||
"AUDIO_PARAM_THRESHOLD": "Поріг",
|
||||
"AUDIO_PARAM_KNEE": "Коліно",
|
||||
"AUDIO_PARAM_RATIO": "Співвідношення",
|
||||
"AUDIO_PARAM_ATTACK": "Атака",
|
||||
"AUDIO_PARAM_RELEASE": "Спад",
|
||||
"AUDIO_EQUALIZER": "Еквалайзер",
|
||||
"AUDIO_COMING_SOON": "Скоро буде",
|
||||
"BTN_RESTART_TOUR": "Перезапустіть підручник",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Перезапустіть навчальний посібник із адаптації",
|
||||
"HINT_SELECT_VIDEO": "Виберіть своє відео тут!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Надсилати повну назву",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Надсилати ID епізоду",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Не надсилати",
|
||||
"LABEL_PRIVACY_SETTINGS": "Налаштування приватності",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Виберіть, які назви вкладки й медіа надсилати до кімнати.",
|
||||
"LABEL_SEND_TAB_TITLE": "Надсилати назву вкладки",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Надсилає до кімнати назву вибраної вкладки браузера.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Назва медіа",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Виберіть, як назви медіа надсилати до кімнати."
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"LANG_CODE": "zh",
|
||||
"HTML_CLASS": "lang-zh",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "房间",
|
||||
"TAB_ROOM_TOOLTIP": "房间设置和连接",
|
||||
"TAB_SYNC": "同步",
|
||||
"TAB_SYNC_TOOLTIP": "视频同步控制和远程操作",
|
||||
"TAB_SETTINGS": "设置",
|
||||
"TAB_SETTINGS_TOOLTIP": "扩展首选项",
|
||||
"TAB_STATUS": "状态",
|
||||
"TAB_STATUS_TOOLTIP": "高级诊断和日志",
|
||||
"BTN_CREATE_ROOM": "+ 创建新房间",
|
||||
"MANUAL_CONNECT_HEADER": "手动连接/高级",
|
||||
"LABEL_SERVER": "服务器",
|
||||
"BTN_SERVER_OFFICIAL": "官方的",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "使用官方可靠服务器",
|
||||
"BTN_SERVER_CUSTOM": "自定义",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "连接到您自己的自托管服务器",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
|
||||
"LABEL_ROOM_ID": "房间 ID",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "您的同步室的唯一标识符",
|
||||
"PLACEHOLDER_ROOM_ID": "输入Room ID",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "您要加入的房间的唯一ID",
|
||||
"LABEL_PASSWORD": "密码(可选)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "可选密码以限制房间访问",
|
||||
"PLACEHOLDER_PASSWORD": "房间密码(可选)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "房间密码(无则留空)",
|
||||
"BTN_JOIN_ROOM": "加入/创建房间",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "连接到房间",
|
||||
"LABEL_PUBLIC_ROOMS": "公共房间",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "该服务器上公开可用的房间列表",
|
||||
"BTN_REFRESH": "刷新",
|
||||
"BTN_REFRESH_TOOLTIP": "刷新公共房间列表",
|
||||
"PUBLIC_ROOMS_REFRESHING": "刷新中...",
|
||||
"BTN_REFRESH_COOLDOWN": "等等{seconds}",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "房间列表刷新正在冷却。在 {seconds} 中重试。",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "公共房间列表刷新冷却中。下次刷新将在 {seconds} 后可用。",
|
||||
"LABEL_ACTIVE_ROOM": "活动室",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "您当前连接的房间",
|
||||
"ACTIVE_ROOM_NONE": "没有任何",
|
||||
"ACTIVE_SERVER_OFFICIAL": "官方服务器",
|
||||
"LABEL_INVITE_LINK": "邀请链接",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "与朋友分享此链接,以便他们可以加入",
|
||||
"LABEL_PEERS_IN_ROOM": "房间里的同伴",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "当前连接到该房间的其他用户",
|
||||
"LABEL_HOST_CONTROL": "主持人控制",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "限制谁可以在此房间中控制播放",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "仅我可以控制播放",
|
||||
"NOTICE_HOST_CONTROLS": "由主持人为所有人控制播放。",
|
||||
"NOTICE_COHOST_HINT": "提示:在下方的参与者列表中将控制权授予单个参与者。",
|
||||
"BADGE_HOST": "主持人",
|
||||
"BADGE_GUEST": "访客",
|
||||
"BADGE_CONTROLLER": "控制者",
|
||||
"BTN_GIVE_CONTROL": "授予控制权",
|
||||
"BTN_REVOKE_CONTROL": "撤销",
|
||||
"BADGE_DESYNCED": "单独",
|
||||
"TOOLTIP_PEER_DESYNCED": "单独观看中 — 忽略主持人的命令",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · 主持人控制本房间",
|
||||
"HCM_DIALOG_BODY": "本房间只有主持人可以控制播放。继续一起观看,还是自己单独观看?",
|
||||
"HCM_DIALOG_STAY": "保持同步",
|
||||
"HCM_DIALOG_SOLO": "自己单独观看",
|
||||
"HCM_BADGE_SOLO": "正在单独观看",
|
||||
"HCM_BADGE_RESYNC": "重新同步",
|
||||
"NO_PEERS_CONNECTED": "没有对等点连接",
|
||||
"BTN_LEAVE_ROOM": "离开房间",
|
||||
"LABEL_SELECT_VIDEO": "选择视频",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "选择包含要同步的视频的浏览器选项卡",
|
||||
"OPTION_SELECT_TAB": "-- 选择一个选项卡 --",
|
||||
"LABEL_REMOTE_CONTROL": "遥控",
|
||||
"BTN_COPY_INVITE": "📋 邀请链接",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "复制邀请链接",
|
||||
"BTN_PLAY": "▶ 播放",
|
||||
"BTN_PLAY_TOOLTIP": "向所有人发送播放命令",
|
||||
"BTN_PAUSE": "⏸ 暂停",
|
||||
"BTN_PAUSE_TOOLTIP": "向所有人发送暂停命令",
|
||||
"BTN_SYNC": "⚡ 同步",
|
||||
"BTN_SYNC_TOOLTIP": "强制所有用户同步",
|
||||
"OPTION_JUMP_TO_OTHERS": "跳转到其他",
|
||||
"OPTION_JUMP_TO_ME": "跳到我这里",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "选择同步目标",
|
||||
"LABEL_LAST_ACTIVITY": "上次活动状态",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "显示最近的播放、暂停或跳转命令",
|
||||
"NO_RECENT_COMMANDS": "没有最近的命令",
|
||||
"LOBBY_HEADER": "剧集大厅",
|
||||
"LOBBY_WAITING_FOR": "🎬 等待:“{title}”",
|
||||
"LOBBY_WAITING_PEERS": "等待同行...",
|
||||
"BTN_SKIP_PLAY": "仍然跳过并播放",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "取消大厅并继续播放",
|
||||
"LOBBY_CONNECT_FIRST": "首先连接到房间",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "您需要通过邀请链接加入房间或创建一个新房间来同步视频。",
|
||||
"BTN_CREATE_ROOM_ALT": "创建新房间",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "创建一个新的随机房间并加入它",
|
||||
"LABEL_USERNAME": "您的用户名",
|
||||
"LABEL_USERNAME_TOOLTIP": "用户名可以帮助其他人识别您。",
|
||||
"PLACEHOLDER_USERNAME": "无名考拉",
|
||||
"LABEL_HIDE_CLUTTER": "隐藏杂乱标签",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "过滤掉非视频选项卡和不相关的域以保持列表干净",
|
||||
"LABEL_AUTO_SYNC_NEXT": "自动同步下一集",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "当情节发生变化时,自动暂停并等待所有对等点,然后一起开始同步。",
|
||||
"LABEL_AUTO_COPY_INVITE": "自动复制邀请链接",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "创建新房间时,自动将邀请链接复制到剪贴板。",
|
||||
"LABEL_NOTIFICATIONS": "浏览器通知",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "当有人加入/离开或播放/暂停时显示本机系统通知。",
|
||||
"LABEL_LANGUAGE": "应用程序语言",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "选择您喜欢的扩展语言",
|
||||
"LABEL_TROUBLESHOOTING": "故障排除",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "用于修复连接问题的工具",
|
||||
"BTN_REGEN_ID": "再生Peer ID",
|
||||
"BTN_REGEN_ID_TOOLTIP": "重新生成您的内部 ID 并重新连接",
|
||||
"REGEN_ID_DESC": "如果您看到“重复身份”错误,请使用此选项。",
|
||||
"REGEN_ID_OTHER_ISSUE": "其他问题?打开 GitHub 问题",
|
||||
"TOAST_ID_REGENERATED": "身份已重新生成 — 正在重新连接…",
|
||||
"LABEL_CONN_STATUS": "连接状态",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "当前WebSocket连接状态",
|
||||
"CONN_STATUS_DISCONNECTED": "已断开连接",
|
||||
"BTN_RETRY": "重试",
|
||||
"BTN_RETRY_TOOLTIP": "尝试重新连接服务器",
|
||||
"BTN_COPY_LOGS": "复制日志",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "将日志复制到剪贴板以便共享",
|
||||
"LABEL_VIDEO_DEBUG": "视频调试信息",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "有关当前所选视频元素的技术详细信息",
|
||||
"VIDEO_DEBUG_EMPTY": "未选择任何选项卡或检测到视频。",
|
||||
"LABEL_HISTORY": "完整的行动历史",
|
||||
"LABEL_HISTORY_TOOLTIP": "房间内所有同步命令的时间日志",
|
||||
"HISTORY_EMPTY": "还没有活动",
|
||||
"LABEL_LOGS": "日志(最后 50 条)",
|
||||
"LABEL_LOGS_TOOLTIP": "用于调试的技术连接日志",
|
||||
"BTN_CLEAR": "清除",
|
||||
"BTN_CLEAR_TOOLTIP": "清除日志输出",
|
||||
"LABEL_GITHUB": "GitHub 存储库",
|
||||
"BTN_ONBOARDING_SKIP": "跳过",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "跳过教程",
|
||||
"BTN_ONBOARDING_NEXT": "下一个",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "进入下一步",
|
||||
"ONBOARDING_1_TITLE": "欢迎来到KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "无论您身在何处,都能完美同步地一起观看视频。让我们快速浏览一下吧!",
|
||||
"ONBOARDING_2_TITLE": "1. 创建房间",
|
||||
"ONBOARDING_2_TEXT": "从这里开始。创建一个房间并与您的朋友分享邀请链接。",
|
||||
"ONBOARDING_3_TITLE": "2. 选择视频",
|
||||
"ONBOARDING_3_TEXT": "导航此处选择您要同步的视频。播放、暂停和搜索——每个人都保持同步。",
|
||||
"ONBOARDING_4_TITLE": "3. 个性化",
|
||||
"ONBOARDING_4_TEXT": "选择一个有趣的用户名,以便您的朋友知道您是谁。",
|
||||
"ONBOARDING_5_TITLE": "你都准备好了!",
|
||||
"ONBOARDING_5_TEXT": "是时候去买点爆米花了。一起观赏吧!",
|
||||
"ERR_CONN_TIMEOUT": "连接超时。请再试一次。",
|
||||
"ERR_INVALID_SERVER_URL": "服务器 URL 格式无效。",
|
||||
"ERR_IDENTITY_NOT_LOADED": "身份尚未加载。稍等片刻,然后重试。",
|
||||
"ERR_NO_PEERS_TIME": "没有其他已知时间的同行。切换到“跳到我这里”。",
|
||||
"ERR_NO_VIDEO_TAB": "无法连接到视频选项卡。",
|
||||
"ERR_SELECT_VIDEO": "请先选择视频!",
|
||||
"TOAST_INVITE_COPIED": "邀请链接已复制!",
|
||||
"TOAST_COPY_FAILED": "无法复制到剪贴板",
|
||||
"TOAST_LOBBY_SKIPPED": "剧集大厅已跳过。",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "无法跳过大厅。",
|
||||
"TOAST_LOGS_COPIED": "复制了!",
|
||||
"TOAST_PEER_JOINED": "{name} 加入房间",
|
||||
"TOAST_PEER_LEFT": "{name} 离开房间",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "已连接",
|
||||
"STATUS_RECONNECTING": "正在重新连接...",
|
||||
"STATUS_CONNECTING": "正在连接...",
|
||||
"STATUS_FAILED": "失败的",
|
||||
"STATUS_DISCONNECTED": "已断开连接",
|
||||
"STATUS_IDLE": "已准备好连接",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync 已准备就绪。加入或创建房间以连接并开始同步。",
|
||||
"BTN_STATE_JOINING": "🚀 正在加入...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 正在重新连接...",
|
||||
"BTN_STATE_PLAYING": "▶ 播放中...",
|
||||
"BTN_STATE_PAUSING": "⏸ 暂停...",
|
||||
"BTN_STATE_SYNCING_GROUP": "正在同步到群组 ({time})...",
|
||||
"BTN_STATE_SYNCING": "正在同步...",
|
||||
"BTN_STATE_SYNCED": "✅ 已同步!",
|
||||
"NOTIF_PLAY": "开始播放",
|
||||
"NOTIF_PAUSE": "暂停播放",
|
||||
"NOTIF_SEEK": "跳转视频",
|
||||
"NOTIF_FORCE_PREPARE": "开始强制同步",
|
||||
"NOTIF_FORCE_EXECUTE": "同步所有人",
|
||||
"DEBUG_NO_TAB": "未选择目标选项卡。",
|
||||
"DEBUG_COMM_FAIL": "无法与标签视频通信。",
|
||||
"EMPTY_PEERS_TITLE": "还没有同行",
|
||||
"EMPTY_PEERS_HINT": "分享您的邀请链接以开始使用",
|
||||
"EMPTY_HISTORY_TITLE": "还没有活动",
|
||||
"EMPTY_HISTORY_HINT": "播放、暂停或跳转以查看历史",
|
||||
"EMPTY_LOGS_TITLE": "无日志",
|
||||
"EMPTY_LOGS_HINT": "连接事件将出现在这里",
|
||||
"EMPTY_ROOMS_TITLE": "没有活动房间",
|
||||
"EMPTY_ROOMS_HINT": "创建房间或刷新以查找公共房间",
|
||||
"LABEL_YOU": "你",
|
||||
"ONBOARDING_DONE": "完毕!",
|
||||
"LABEL_LOBBY_PEER_READY": "准备好",
|
||||
"LABEL_LOBBY_PEER_LOADING": "加载中...",
|
||||
"LABEL_PASSWORD_PROTECTED": "密码保护",
|
||||
"LABEL_PEERS_COUNT": "{count} 同行",
|
||||
"LABEL_CUSTOM_SERVER": "定制服务器",
|
||||
"BTN_STATE_CREATING": "🚀 正在创建房间...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — 剧集同步失败",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "自动同步取消:{reason}。您可能需要手动同步。",
|
||||
"LOBBY_CANCEL_TIMEOUT": "暂停",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "超时(已恢复)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "所有其他同伴都离开了",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "超时——并非所有同行都加载了该剧集",
|
||||
"LOBBY_CANCEL_USER": "已被用户取消",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync 错误",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ 评价我们",
|
||||
"FOOTER_SUPPORT_PROMPT": "喜欢 KoalaSync 吗?留下评论!",
|
||||
"LABEL_AUDIO_PROCESSING": "音频处理",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "将压缩等音频效果应用于视频播放",
|
||||
"AUDIO_OPEN_SETTINGS": "打开",
|
||||
"NEW_FEATURE_AUDIO": "新功能:音频处理 - 尝试压缩器!",
|
||||
"AUDIO_BACK": "← 返回",
|
||||
"AUDIO_PAGE_TITLE": "音频设置",
|
||||
"AUDIO_MASTER_TOGGLE": "音频处理",
|
||||
"AUDIO_COMPRESSOR": "压缩器",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "启用",
|
||||
"AUDIO_PRESET": "预设",
|
||||
"AUDIO_PRESET_RECOMMENDED": "推荐",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "动态范围",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "声音增强",
|
||||
"AUDIO_PRESET_SMOOTH": "平滑",
|
||||
"AUDIO_PRESET_CUSTOM": "自定义",
|
||||
"AUDIO_PARAM_THRESHOLD": "阈值",
|
||||
"AUDIO_PARAM_KNEE": "拐点",
|
||||
"AUDIO_PARAM_RATIO": "比率",
|
||||
"AUDIO_PARAM_ATTACK": "起音",
|
||||
"AUDIO_PARAM_RELEASE": "释放",
|
||||
"AUDIO_EQUALIZER": "均衡器",
|
||||
"AUDIO_COMING_SOON": "即将推出",
|
||||
"BTN_RESTART_TOUR": "重启教程",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "重新启动入门教程",
|
||||
"HINT_SELECT_VIDEO": "在这里选择您的视频!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "发送完整标题",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "发送剧集 ID",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "不发送",
|
||||
"LABEL_PRIVACY_SETTINGS": "隐私设置",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "选择要发送到房间的标签页标题和媒体标题。",
|
||||
"LABEL_SEND_TAB_TITLE": "发送标签页标题",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "将所选浏览器标签页的标题发送到房间。",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "媒体标题",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "选择媒体标题发送到房间的方式。"
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.2.4",
|
||||
"version": "2.5.2",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export function initTabManager({
|
||||
getCurrentTabId,
|
||||
setCurrentTabId,
|
||||
setCurrentTabTitle,
|
||||
setLastContentHeartbeatAt,
|
||||
setRoomIdleSince,
|
||||
getCurrentRoom,
|
||||
getPeerId,
|
||||
getStorageInitialized,
|
||||
updateBadgeStatus,
|
||||
addLog,
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
injectContentScript,
|
||||
ensureState,
|
||||
EVENTS
|
||||
}) {
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
await ensureState();
|
||||
const tabId = getCurrentTabId();
|
||||
if (!tabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: changes.audioSettings.newValue
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
await ensureState();
|
||||
if (tabId === getCurrentTabId()) {
|
||||
const wasInRoom = !!getCurrentRoom();
|
||||
setCurrentTabId(null);
|
||||
setCurrentTabTitle(null);
|
||||
setLastContentHeartbeatAt(null);
|
||||
const now = Date.now();
|
||||
setRoomIdleSince(now);
|
||||
chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: now,
|
||||
lastContentHeartbeatAt: null
|
||||
});
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
|
||||
if (wasInRoom) {
|
||||
const roomAtClose = getCurrentRoom();
|
||||
getSettings().then(settings => {
|
||||
if (getCurrentRoom() !== roomAtClose) return;
|
||||
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId: getPeerId(),
|
||||
playbackState: 'paused',
|
||||
currentTime: null,
|
||||
mediaTitle: null,
|
||||
username: settings.username,
|
||||
tabTitle: null
|
||||
});
|
||||
|
||||
const room = getCurrentRoom();
|
||||
if (room && Array.isArray(room.peers)) {
|
||||
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
|
||||
if (me && typeof me === 'object') {
|
||||
me.playbackState = 'paused';
|
||||
me.currentTime = null;
|
||||
me.mediaTitle = null;
|
||||
me.tabTitle = null;
|
||||
me.lastHeartbeat = Date.now();
|
||||
if (getStorageInitialized()) {
|
||||
chrome.storage.session.set({ currentRoom: room });
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
await ensureState();
|
||||
const curTabId = getCurrentTabId();
|
||||
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
|
||||
injectContentScript(tabId)
|
||||
.then(() => applyAudioSettingsToTab(tabId))
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
+68
-11
@@ -391,6 +391,7 @@
|
||||
<button class="tab-btn" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
|
||||
<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
|
||||
<button class="tab-btn" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
|
||||
<button id="devToolsTabBtn" class="tab-btn" data-tab="tab-devtools" style="display:none;">Dev</button>
|
||||
</div>
|
||||
|
||||
<!-- Room Tab -->
|
||||
@@ -402,14 +403,18 @@
|
||||
<details style="margin-bottom: 12px; background: var(--card); border: 1px solid #334155; border-radius: 8px; padding: 8px;">
|
||||
<summary style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; cursor: pointer; outline: none;" data-i18n="MANUAL_CONNECT_HEADER">Manual Connect / Advanced</summary>
|
||||
<div style="margin-top: 12px;">
|
||||
<div class="form-group">
|
||||
<label title="Select which KoalaSync server to use" data-i18n="LABEL_SERVER">Server</label>
|
||||
<div style="display:flex; gap:4px; margin-bottom:8px;">
|
||||
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_OFFICIAL" data-i18n-title="BTN_SERVER_OFFICIAL_TOOLTIP" title="Use the official reliable server">Official</button>
|
||||
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_CUSTOM" data-i18n-title="BTN_SERVER_CUSTOM_TOOLTIP" title="Connect to your own self-hosted server">Custom</button>
|
||||
<!-- Server selection — its own concern, kept visually separate from the room fields. -->
|
||||
<div style="background: var(--bg); border: 1px solid #334155; border-radius: 8px; padding: 10px; margin-bottom: 14px;">
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label title="Select which KoalaSync server to use" data-i18n="LABEL_SERVER">Server</label>
|
||||
<div style="display:flex; gap:4px; margin-bottom:0;">
|
||||
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_OFFICIAL" data-i18n-title="BTN_SERVER_OFFICIAL_TOOLTIP" title="Use the official reliable server">Official</button>
|
||||
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_CUSTOM" data-i18n-title="BTN_SERVER_CUSTOM_TOOLTIP" title="Connect to your own self-hosted server">Custom</button>
|
||||
</div>
|
||||
<input type="text" id="serverUrl" data-i18n-placeholder="PLACEHOLDER_SERVER_URL" placeholder="wss://your-server:3000" style="display:none; margin-top:8px;">
|
||||
</div>
|
||||
<input type="text" id="serverUrl" data-i18n-placeholder="PLACEHOLDER_SERVER_URL" placeholder="wss://your-server:3000" style="display:none;">
|
||||
</div>
|
||||
<!-- Room credentials — distinct group below the server picker. -->
|
||||
<div class="form-group">
|
||||
<label title="The unique identifier for your sync room" data-i18n="LABEL_ROOM_ID" data-i18n-title="LABEL_ROOM_ID_TOOLTIP">Room ID</label>
|
||||
<input type="text" id="roomId" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
|
||||
@@ -450,6 +455,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hostControlCard" class="info-card" style="margin-bottom: 20px; display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label style="margin:0;" title="Restrict who can control playback in this room" data-i18n="LABEL_HOST_CONTROL" data-i18n-title="LABEL_HOST_CONTROL_TOOLTIP">Host Control</label>
|
||||
<span id="hostRoleBadge" style="font-size:10px; font-weight:700; text-transform:uppercase; padding:2px 8px; border-radius:8px; background:var(--accent); color:#fff;"></span>
|
||||
</div>
|
||||
<div id="hostControlToggleRow" style="display:none; align-items:center; justify-content:space-between; gap:12px; margin-top:12px;">
|
||||
<span style="font-size:12px; color:var(--text); line-height:1.3;" data-i18n="LABEL_HOST_ONLY_TOGGLE">Only I can control playback</span>
|
||||
<label class="toggle-switch" style="flex:none;">
|
||||
<input type="checkbox" id="hostControlToggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="hostControlCohostHint" style="display:none; margin-top:10px; padding-top:10px; border-top:1px solid #334155; font-size:11px; color:var(--text-muted); line-height:1.4;" data-i18n="NOTICE_COHOST_HINT">Tip: grant control to individual viewers in the participant list below.</div>
|
||||
<div id="hostControlGuestNote" style="display:none; margin-top:10px; font-size:12px; color:var(--text-muted);" data-i18n="NOTICE_HOST_CONTROLS">The host controls playback for everyone.</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label title="Other users currently connected to this room" data-i18n="LABEL_PEERS_IN_ROOM" data-i18n-title="LABEL_PEERS_IN_ROOM_TOOLTIP">Peers in Room</label>
|
||||
<div id="peerList" class="info-card">
|
||||
@@ -530,6 +551,25 @@
|
||||
<input type="text" id="username" data-i18n-placeholder="PLACEHOLDER_USERNAME" placeholder="Anonymous Koala" maxlength="20" style="width: 150px;">
|
||||
</div>
|
||||
|
||||
<details class="form-group" style="background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<summary style="cursor: pointer; font-size: 12px; font-weight: 700; color: var(--text);" title="Choose which titles are sent to the room." data-i18n="LABEL_PRIVACY_SETTINGS" data-i18n-title="LABEL_PRIVACY_SETTINGS_TOOLTIP">Privacy Settings</summary>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px;">
|
||||
<label style="margin-bottom: 0; cursor: help;" title="Send the selected browser tab title to the room." data-i18n="LABEL_SEND_TAB_TITLE" data-i18n-title="LABEL_SEND_TAB_TITLE_TOOLTIP">Send tab title</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="sendTabTitle" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px;">
|
||||
<label style="margin-bottom: 0; cursor: help;" title="Choose how media titles are sent to the room." data-i18n="LABEL_MEDIA_TITLE_PRIVACY" data-i18n-title="LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP">Media title</label>
|
||||
<select id="mediaTitlePrivacyMode" style="width: 165px; background: var(--bg); border: 1px solid #334155; color: white; padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer; outline: none; font-family: inherit;">
|
||||
<option value="full" data-i18n="OPTION_TITLE_PRIVACY_FULL">Send full title</option>
|
||||
<option value="episode" data-i18n="OPTION_TITLE_PRIVACY_EPISODE">Send episode ID</option>
|
||||
<option value="hidden" data-i18n="OPTION_TITLE_PRIVACY_HIDDEN">Do not send</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0; cursor: help;" title="Filters out non-video tabs and unrelated domains to keep the list clean" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label>
|
||||
<label class="toggle-switch">
|
||||
@@ -584,6 +624,8 @@
|
||||
<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>
|
||||
</div>
|
||||
@@ -600,7 +642,7 @@
|
||||
<div style="font-size: 9px; color: var(--text-muted); opacity: 0.6; margin-bottom: 6px; line-height: 1.3;" data-i18n="FOOTER_SUPPORT_PROMPT">Enjoying KoalaSync? Leave a review!</div>
|
||||
<a id="settingsReviewLink" href="#" target="_blank" data-i18n="FOOTER_REVIEW">★ Rate us</a>
|
||||
<span> · </span>
|
||||
<a id="settingsSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">☕ Buy me a coffee</a>
|
||||
<a id="settingsSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">Support KoalaSync</a>
|
||||
<span> · </span>
|
||||
<span id="settingsVersion">v0.0.0</span>
|
||||
</div>
|
||||
@@ -611,7 +653,10 @@
|
||||
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
|
||||
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
|
||||
<span id="connDot" class="status-dot status-offline"></span>
|
||||
<span id="connText" style="flex:1;">Disconnected</span>
|
||||
<span style="flex:1; display:flex; align-items:center; gap:6px; min-width:0;">
|
||||
<span id="connText">Disconnected</span>
|
||||
<span id="connInfo" data-i18n-title="STATUS_IDLE_TOOLTIP" title="KoalaSync is ready. Join or create a room to connect and start syncing." style="display:none; cursor:help; flex:none; width:15px; height:15px; line-height:14px; text-align:center; border-radius:50%; border:1px solid var(--text-muted); color:var(--text-muted); font-size:10px; font-weight:700; font-style:normal;">i</span>
|
||||
</span>
|
||||
<span id="connPing" style="font-size:11px; font-family:monospace; font-weight:600; opacity:0.8;"></span>
|
||||
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px; margin: 0;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
|
||||
@@ -641,13 +686,25 @@
|
||||
<div style="font-size: 9px; color: var(--text-muted); opacity: 0.6; margin-bottom: 6px; line-height: 1.3;" data-i18n="FOOTER_SUPPORT_PROMPT">Enjoying KoalaSync? Leave a review!</div>
|
||||
<a id="devReviewLink" href="#" target="_blank" data-i18n="FOOTER_REVIEW">★ Rate us</a>
|
||||
<span> · </span>
|
||||
<a id="devSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">☕ Buy me a coffee</a>
|
||||
<a id="devSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">Support KoalaSync</a>
|
||||
<span> · </span>
|
||||
<span id="appVersion">v0.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-devtools" class="tab-content">
|
||||
<label>Remote Seek</label>
|
||||
<div class="info-card" style="display:flex; gap:8px; margin-bottom:15px;">
|
||||
<button id="remoteSeekBack" class="secondary" style="flex:1; font-size:12px;">-30s</button>
|
||||
<button id="remoteSeekForward" class="secondary" style="flex:1; font-size:12px;">+30s</button>
|
||||
</div>
|
||||
<button id="remoteSeekFiveMin" class="secondary" style="width:100%; font-size:12px; margin-bottom:15px;">Seek 5:00</button>
|
||||
<div style="font-size:10px; color:var(--text-muted); text-align:center; margin-top:10px; opacity:0.7;">
|
||||
Build: <span id="buildIdentifier">__BUILD_TIMESTAMP__</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js" type="module"></script>
|
||||
|
||||
<!-- Onboarding Overlay -->
|
||||
@@ -659,14 +716,14 @@
|
||||
<div id="onboarding-card" style="position:fixed; background:var(--card); padding:16px 20px; border-radius:12px; width:280px; box-shadow:0 8px 32px rgba(0,0,0,0.5); z-index:1002; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-sizing:border-box; border:1px solid #334155; opacity:0; transform: scale(0.9); display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
|
||||
<span id="onboarding-icon" style="font-size:24px;">👋</span>
|
||||
<span id="onboarding-step-indicator" style="font-size:10px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:0.5px;">Step 1 of 3</span>
|
||||
<span id="onboarding-step-indicator" style="font-size:10px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:0.5px;">Step 1 of 5</span>
|
||||
</div>
|
||||
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 6px; font-size:14px; font-weight:700; text-align:left;">Welcome to KoalaSync!</h2>
|
||||
<p id="onboarding-text" style="color:var(--text-muted); font-size:12px; margin:0 0 16px; line-height:1.4; text-align:left;">Let's get you started.</p>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div id="onboarding-progress-container" style="height:4px; background:#334155; border-radius:2px; margin-bottom:16px; overflow:hidden;">
|
||||
<div id="onboarding-progress-bar" style="height:100%; width:33%; background:var(--accent); transition:width 0.3s ease;"></div>
|
||||
<div id="onboarding-progress-bar" style="height:100%; width:20%; background:var(--accent); transition:width 0.3s ease;"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; justify-content:space-between; align-items:center;">
|
||||
|
||||
+413
-59
@@ -2,6 +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, normalizeTabTitle } from './title-privacy.js';
|
||||
|
||||
|
||||
const elements = {
|
||||
@@ -16,6 +17,7 @@ const elements = {
|
||||
clearLogs: document.getElementById('clearLogs'),
|
||||
connDot: document.getElementById('connDot'),
|
||||
connText: document.getElementById('connText'),
|
||||
connInfo: document.getElementById('connInfo'),
|
||||
connPing: document.getElementById('connPing'),
|
||||
serverUrl: document.getElementById('serverUrl'),
|
||||
serverOfficial: document.getElementById('serverOfficial'),
|
||||
@@ -40,6 +42,12 @@ const elements = {
|
||||
retryBtn: document.getElementById('retryBtn'),
|
||||
sectionJoin: document.getElementById('section-join'),
|
||||
sectionActive: document.getElementById('section-active'),
|
||||
hostControlCard: document.getElementById('hostControlCard'),
|
||||
hostRoleBadge: document.getElementById('hostRoleBadge'),
|
||||
hostControlToggleRow: document.getElementById('hostControlToggleRow'),
|
||||
hostControlToggle: document.getElementById('hostControlToggle'),
|
||||
hostControlGuestNote: document.getElementById('hostControlGuestNote'),
|
||||
hostControlCohostHint: document.getElementById('hostControlCohostHint'),
|
||||
activeRoomId: document.getElementById('activeRoomId'),
|
||||
activeServer: document.getElementById('activeServer'),
|
||||
peerListSync: document.getElementById('peerListSync'),
|
||||
@@ -47,6 +55,8 @@ const elements = {
|
||||
playBtn: document.getElementById('playBtn'),
|
||||
pauseBtn: document.getElementById('pauseBtn'),
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
sendTabTitle: document.getElementById('sendTabTitle'),
|
||||
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
lobbyTitle: document.getElementById('lobbyTitle'),
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||||
@@ -61,7 +71,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;
|
||||
@@ -78,6 +92,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;
|
||||
@@ -175,19 +202,9 @@ function setRoomRefreshCooldown() {
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
|
||||
// Migrate preferences from sync → local for existing users
|
||||
const oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
|
||||
const toMigrate = {};
|
||||
for (const key of ['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']) {
|
||||
if (localData[key] === undefined && oldSync[key] !== undefined) {
|
||||
toMigrate[key] = oldSync[key];
|
||||
localData[key] = oldSync[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(toMigrate).length) {
|
||||
await chrome.storage.local.set(toMigrate);
|
||||
}
|
||||
// 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']);
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -210,8 +227,13 @@ 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;
|
||||
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
|
||||
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
|
||||
if (elements.mediaTitlePrivacyMode) elements.mediaTitlePrivacyMode.value = mediaTitlePrivacyMode;
|
||||
if (elements.forceSyncMode) elements.forceSyncMode.value = localData.forceSyncMode || 'jump-to-others';
|
||||
if (elements.browserNotifications) elements.browserNotifications.checked = localData.browserNotifications === true;
|
||||
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = localData.autoCopyInvite !== false;
|
||||
@@ -237,8 +259,7 @@ async function init() {
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
|
||||
// Default connection status (localized) before async check
|
||||
applyConnectionStatus('disconnected');
|
||||
// Initial Status Check (status shows via GET_STATUS below)
|
||||
|
||||
// Initial Status Check
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
@@ -254,7 +275,15 @@ async function init() {
|
||||
updatePingDisplay(res.ping);
|
||||
updatePeerList(res.peers);
|
||||
lastKnownPeers = res.peers || [];
|
||||
updateHostControlUI({ controlMode: res.controlMode, amHost: res.amHost, amController: res.amController, controllers: res.controllers, hostPeerId: res.hostPeerId, hostControlSupported: res.hostControlSupported, coHostSupported: res.coHostSupported, inRoom: res.status === 'connected' });
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
// If user has a room configured but background is not connected (disconnected or idle),
|
||||
// trigger connection now — the popup opening is explicit user intent.
|
||||
if ((res.status === 'disconnected' || res.status === 'idle') && localData.roomId) {
|
||||
chrome.runtime.sendMessage({ type: 'CONNECT' }).catch(() => {});
|
||||
applyConnectionStatus('connecting');
|
||||
}
|
||||
|
||||
// Populate Tabs using the background's targetTabId
|
||||
await populateTabs(res.peers, res.targetTabId);
|
||||
@@ -266,13 +295,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();
|
||||
}
|
||||
@@ -306,6 +335,98 @@ function toggleUIState(inRoom) {
|
||||
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// --- Host Control Mode UI ---
|
||||
// True when we're a guest in a host-only room → remote-control buttons are locked.
|
||||
let hcmGuestLocked = false;
|
||||
|
||||
// Co-Host state mirrored for the peer-list renderer (promote/demote + role badges).
|
||||
let hcmAmOwner = false;
|
||||
let hcmControllers = [];
|
||||
let hcmCoHostSupported = false;
|
||||
let hcmOwnerPeerId = null;
|
||||
let hcmRoomIsHostOnly = false;
|
||||
|
||||
function updateHostControlUI(state) {
|
||||
const s = state || {};
|
||||
const controlMode = s.controlMode;
|
||||
const amHost = !!s.amHost;
|
||||
const amController = !!s.amController;
|
||||
const hostOnly = controlMode === 'host-only';
|
||||
|
||||
// Stash for the peer-list renderer, then force it to re-render with new roles.
|
||||
hcmAmOwner = amHost;
|
||||
hcmControllers = Array.isArray(s.controllers) ? s.controllers : [];
|
||||
hcmCoHostSupported = !!s.coHostSupported;
|
||||
hcmOwnerPeerId = s.hostPeerId || null;
|
||||
hcmRoomIsHostOnly = hostOnly;
|
||||
lastPeersJson = '';
|
||||
if (activePeers) updatePeerList(activePeers);
|
||||
|
||||
const card = elements.hostControlCard;
|
||||
if (!card) return;
|
||||
// Explicit capability advertised by the relay. Against an older relay it's
|
||||
// false/absent → feature unavailable, so hide the card instead of misleading UI.
|
||||
const serverSupportsHostControl = !!s.hostControlSupported;
|
||||
// Show the card only when meaningful: owner always (to manage); a non-controller
|
||||
// guest only while host-only is active. A guest in a normal room sees nothing.
|
||||
const show = s.inRoom && serverSupportsHostControl && (amHost || hostOnly);
|
||||
if (!show) {
|
||||
card.style.display = 'none';
|
||||
hcmGuestLocked = false;
|
||||
setRemoteControlsLocked(false);
|
||||
return;
|
||||
}
|
||||
card.style.display = 'block';
|
||||
if (elements.hostRoleBadge) {
|
||||
const role = amHost ? (getMessage('BADGE_HOST') || 'Host')
|
||||
: amController ? (getMessage('BADGE_CONTROLLER') || 'Controller')
|
||||
: (getMessage('BADGE_GUEST') || 'Guest');
|
||||
elements.hostRoleBadge.textContent = role;
|
||||
elements.hostRoleBadge.style.background = (amHost || amController) ? 'var(--accent)' : 'var(--text-muted)';
|
||||
}
|
||||
if (elements.hostControlToggleRow) elements.hostControlToggleRow.style.display = amHost ? 'flex' : 'none';
|
||||
if (elements.hostControlToggle) elements.hostControlToggle.checked = hostOnly;
|
||||
if (elements.hostControlGuestNote) elements.hostControlGuestNote.style.display = (!amController && hostOnly) ? 'block' : 'none';
|
||||
// Owner-only discoverability hint: co-hosting is granted down in the peer list,
|
||||
// which isn't obvious from this card. Only show it when it's actionable — owner,
|
||||
// relay supports co-host, and the room is locked to host-only (the buttons only
|
||||
// render in that mode).
|
||||
if (elements.hostControlCohostHint) elements.hostControlCohostHint.style.display = (amHost && !!s.coHostSupported && hostOnly) ? 'block' : 'none';
|
||||
|
||||
// Only a non-controller is locked out of the remote controls.
|
||||
hcmGuestLocked = (!amController && hostOnly);
|
||||
setRemoteControlsLocked(hcmGuestLocked);
|
||||
}
|
||||
|
||||
function setRemoteControlsLocked(locked) {
|
||||
[elements.playBtn, elements.pauseBtn, elements.forceSyncBtn].forEach(btn => {
|
||||
if (!btn) return;
|
||||
btn.disabled = locked;
|
||||
btn.style.opacity = locked ? '0.5' : '';
|
||||
btn.style.cursor = locked ? 'not-allowed' : '';
|
||||
btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '';
|
||||
});
|
||||
// Always restore the default labels. The action handlers leave the text in a
|
||||
// transitional state ("Playing..." / "Pausing...") and the 2.5s safety reset
|
||||
// skips the refresh while guest-locked — so reset on BOTH transitions: on lock
|
||||
// (host enabled host-only just as the guest clicked → don't freeze "Playing...")
|
||||
// and on unlock (L-1).
|
||||
if (elements.playBtn) elements.playBtn.textContent = getMessage('BTN_PLAY') || 'Play';
|
||||
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
|
||||
}
|
||||
|
||||
if (elements.hostControlToggle) {
|
||||
elements.hostControlToggle.addEventListener('change', () => {
|
||||
const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone';
|
||||
chrome.runtime.sendMessage({ type: 'SET_CONTROL_MODE', controlMode: mode }, (res) => {
|
||||
// Server broadcasts CONTROL_MODE back to refresh the UI; revert on failure.
|
||||
if (chrome.runtime.lastError || !res || res.status !== 'ok') {
|
||||
elements.hostControlToggle.checked = (mode === 'everyone');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
|
||||
const inRoom = !!roomId;
|
||||
toggleUIState(inRoom);
|
||||
@@ -497,6 +618,24 @@ function renderEmpty(container, type) {
|
||||
wrapper.appendChild(iconDiv);
|
||||
wrapper.appendChild(titleDiv);
|
||||
wrapper.appendChild(hintDiv);
|
||||
|
||||
// "No peers yet" — turn the hint into a one-click action so the user can
|
||||
// immediately copy and share the invite link instead of hunting for it.
|
||||
if (type === 'peers' && elements.inviteLink && elements.inviteLink.value) {
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'primary';
|
||||
copyBtn.style.cssText = 'display:inline-block; width:auto; margin-top:12px; padding:6px 14px; font-size:11px;';
|
||||
copyBtn.textContent = getMessage('BTN_COPY_INVITE');
|
||||
copyBtn.title = getMessage('BTN_COPY_INVITE_TOOLTIP');
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value)
|
||||
.then(() => showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000))
|
||||
.catch(() => showToast(getMessage('TOAST_COPY_FAILED'), 'error'));
|
||||
});
|
||||
wrapper.appendChild(copyBtn);
|
||||
}
|
||||
|
||||
container.replaceChildren(wrapper);
|
||||
}
|
||||
|
||||
@@ -562,6 +701,11 @@ function updatePeerList(peers) {
|
||||
|
||||
header.appendChild(nameSpan);
|
||||
|
||||
// Right-side badges + actions, kept in one group so they sit together
|
||||
// on the right instead of being scattered by the header's space-between.
|
||||
const rightGroup = document.createElement('div');
|
||||
rightGroup.style.cssText = 'display:flex; align-items:center; gap:6px; flex-shrink:0;';
|
||||
|
||||
// Volume Icon (Top Right)
|
||||
if (p.volume !== undefined && p.volume !== null) {
|
||||
const volIcon = document.createElement('div');
|
||||
@@ -575,9 +719,57 @@ function updatePeerList(peers) {
|
||||
const you = document.createElement('span');
|
||||
you.style.cssText = 'font-size:10px; color:var(--accent); font-weight:bold;';
|
||||
you.textContent = getMessage('LABEL_YOU') || 'YOU';
|
||||
header.appendChild(you);
|
||||
rightGroup.appendChild(you);
|
||||
}
|
||||
|
||||
// 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;';
|
||||
const soloText = getMessage('BADGE_DESYNCED') || 'Solo';
|
||||
solo.textContent = soloText;
|
||||
solo.title = getMessage('TOOLTIP_PEER_DESYNCED') || soloText;
|
||||
rightGroup.appendChild(solo);
|
||||
}
|
||||
|
||||
// Co-Host: role badges + the owner's promote/demote control. Only meaningful
|
||||
// once the room is locked to host-only — in 'everyone' mode anyone can already
|
||||
// control, so roles are moot and we show nothing (no badge/button noise).
|
||||
// The promote/demote button additionally requires owner privileges (below).
|
||||
const showRoles = !!hcmOwnerPeerId && hcmRoomIsHostOnly;
|
||||
if (showRoles) {
|
||||
const isOwner = pId === hcmOwnerPeerId;
|
||||
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.textContent = isOwner ? (getMessage('BADGE_HOST') || 'Host') : (getMessage('BADGE_CONTROLLER') || 'Controller');
|
||||
rightGroup.appendChild(roleBadge);
|
||||
}
|
||||
// Owner can promote/demote any non-owner peer.
|
||||
if (hcmAmOwner && hcmCoHostSupported && !isOwner) {
|
||||
const btn = document.createElement('button');
|
||||
const revoke = isController;
|
||||
btn.style.cssText = `font-size:10px; padding:3px 9px; border-radius:6px; cursor:pointer; white-space:nowrap; font-weight:600; transition:background .15s,color .15s; border:1px solid ${revoke ? 'var(--text-muted)' : 'var(--accent)'}; background:transparent; color:${revoke ? 'var(--text-muted)' : 'var(--accent)'};`;
|
||||
btn.textContent = revoke ? (getMessage('BTN_REVOKE_CONTROL') || 'Revoke') : (getMessage('BTN_GIVE_CONTROL') || 'Give control');
|
||||
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.addEventListener('mouseleave', () => {
|
||||
btn.style.background = 'transparent';
|
||||
btn.style.color = revoke ? 'var(--text-muted)' : 'var(--accent)';
|
||||
});
|
||||
btn.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'SET_PEER_ROLE', peerId: pId, controller: !isController }, () => {});
|
||||
});
|
||||
rightGroup.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
if (rightGroup.childNodes.length) header.appendChild(rightGroup);
|
||||
|
||||
peerItem.appendChild(header);
|
||||
|
||||
// Media Info
|
||||
@@ -729,7 +921,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();
|
||||
@@ -804,19 +996,33 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
}
|
||||
|
||||
function applyConnectionStatus(status) {
|
||||
// Coerce 'idle' to 'disconnected' if a room is actively configured.
|
||||
// An 'idle' status with a configured room indicates the background
|
||||
// worker dropped connection intent due to a server error.
|
||||
if (status === 'idle' && elements.sectionActive && elements.sectionActive.style.display !== 'none') {
|
||||
status = 'disconnected';
|
||||
}
|
||||
|
||||
const connected = status === 'connected';
|
||||
const connecting = status === 'connecting';
|
||||
const reconnecting = status === 'reconnecting';
|
||||
// 'idle' = not in a room and not trying to connect. This is the normal
|
||||
// resting state (lazy connect), NOT an error — surface it neutrally.
|
||||
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';
|
||||
@@ -827,7 +1033,12 @@ function applyConnectionStatus(status) {
|
||||
}
|
||||
|
||||
if (elements.connText) {
|
||||
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : getMessage('STATUS_DISCONNECTED')));
|
||||
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : (idle ? getMessage('STATUS_IDLE') : getMessage('STATUS_DISCONNECTED'))));
|
||||
}
|
||||
|
||||
// Show the info "i" + tooltip only in the idle state.
|
||||
if (elements.connInfo) {
|
||||
elements.connInfo.style.display = idle ? '' : 'none';
|
||||
}
|
||||
if (!connected) {
|
||||
updatePingDisplay(null);
|
||||
@@ -1036,6 +1247,22 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
if (elements.sendTabTitle) {
|
||||
elements.sendTabTitle.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
|
||||
chrome.runtime.sendMessage({ type: 'TITLE_PRIVACY_CHANGED' }).catch(() => {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.mediaTitlePrivacyMode) {
|
||||
elements.mediaTitlePrivacyMode.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ mediaTitlePrivacyMode: elements.mediaTitlePrivacyMode.value }, () => {
|
||||
chrome.runtime.sendMessage({ type: 'TITLE_PRIVACY_CHANGED' }).catch(() => {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
elements.browserNotifications.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ browserNotifications: elements.browserNotifications.checked });
|
||||
});
|
||||
@@ -1066,8 +1293,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) {
|
||||
@@ -1111,7 +1340,11 @@ if (elements.langSelector) {
|
||||
elements.serverUrl.addEventListener('change', () => {
|
||||
let url = elements.serverUrl.value.trim();
|
||||
if (url && !url.includes('://')) {
|
||||
url = 'ws://' + url;
|
||||
// Default to secure wss:// — plain ws:// is only valid for a local relay
|
||||
// (and the background rejects/upgrades ws:// for any remote host anyway).
|
||||
const host = url.split('/')[0].split(':')[0].toLowerCase();
|
||||
const isLocal = host === 'localhost' || host === '127.0.0.1';
|
||||
url = (isLocal ? 'ws://' : 'wss://') + url;
|
||||
elements.serverUrl.value = url;
|
||||
chrome.storage.local.set({ serverUrl: url });
|
||||
}
|
||||
@@ -1195,11 +1428,23 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
|
||||
if (joinBtnTimeout) clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = setTimeout(() => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.status === 'connecting') {
|
||||
joinBtnTimeout = setTimeout(() => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
}, 15000);
|
||||
return;
|
||||
}
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
if (res && res.status !== 'connected') showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const serverUrl = elements.serverUrl.value.trim();
|
||||
@@ -1210,16 +1455,19 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
if (useCustom && serverUrl) {
|
||||
try {
|
||||
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'ws://' + serverUrl;
|
||||
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'wss://' + serverUrl;
|
||||
new URL(urlToCheck);
|
||||
} catch (_e) {
|
||||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
@@ -1317,6 +1565,7 @@ elements.targetTab.addEventListener('change', () => {
|
||||
});
|
||||
|
||||
elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (hcmGuestLocked) return; // guest in host-only room — backstop (M-2/L-3)
|
||||
if (elements.forceSyncBtn.disabled) return;
|
||||
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
@@ -1339,8 +1588,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'));
|
||||
@@ -1358,7 +1608,10 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
|
||||
const syncTimeoutMs = peerCount === 0 ? 3000 : 12000;
|
||||
const forceSyncReset = () => {
|
||||
if (!forceSyncDone) {
|
||||
// Don't unlock a button that's locked because we became a guest mid-flight —
|
||||
// hcmGuestLocked is the source of truth for the lock state, and the next
|
||||
// CONTROL_MODE update restores the correct label.
|
||||
if (!forceSyncDone && !hcmGuestLocked) {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
}
|
||||
@@ -1366,38 +1619,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);
|
||||
@@ -1405,6 +1677,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
elements.playBtn.addEventListener('click', () => {
|
||||
if (hcmGuestLocked) return; // guest in host-only room — backstop
|
||||
if (!elements.targetTab.value) {
|
||||
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
|
||||
return;
|
||||
@@ -1423,7 +1696,7 @@ elements.playBtn.addEventListener('click', () => {
|
||||
});
|
||||
// Safety reset: restore button after 2.5s in case no peers respond
|
||||
setTimeout(() => {
|
||||
if (elements.playBtn.disabled) {
|
||||
if (elements.playBtn.disabled && !hcmGuestLocked) {
|
||||
elements.playBtn.textContent = getMessage('BTN_PLAY');
|
||||
elements.playBtn.disabled = false;
|
||||
}
|
||||
@@ -1431,6 +1704,7 @@ elements.playBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.pauseBtn.addEventListener('click', () => {
|
||||
if (hcmGuestLocked) return; // guest in host-only room — backstop
|
||||
if (!elements.targetTab.value) {
|
||||
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
|
||||
return;
|
||||
@@ -1449,19 +1723,54 @@ elements.pauseBtn.addEventListener('click', () => {
|
||||
});
|
||||
// Safety reset: restore button after 2.5s in case no peers respond
|
||||
setTimeout(() => {
|
||||
if (elements.pauseBtn.disabled) {
|
||||
if (elements.pauseBtn.disabled && !hcmGuestLocked) {
|
||||
elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
|
||||
elements.pauseBtn.disabled = false;
|
||||
}
|
||||
}, 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 = '';
|
||||
});
|
||||
});
|
||||
|
||||
if (elements.regenId) {
|
||||
elements.regenId.addEventListener('click', () => {
|
||||
elements.regenId.disabled = true;
|
||||
chrome.runtime.sendMessage({ type: 'REGENERATE_ID' }, (res) => {
|
||||
elements.regenId.disabled = false;
|
||||
if (chrome.runtime.lastError || !res || !res.peerId) {
|
||||
showToast(getMessage('TOAST_ID_REGENERATED') || 'Failed to regenerate identity', 'error');
|
||||
return;
|
||||
}
|
||||
showToast(getMessage('TOAST_ID_REGENERATED') || 'Identity regenerated — reconnecting…', 'success', 3000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
elements.copyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.copyInvite.textContent;
|
||||
@@ -1607,7 +1916,13 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
} else if (msg.type === 'PEER_UPDATE') {
|
||||
updatePeerList(msg.peers);
|
||||
if (msg.peers) detectPeerChanges(msg.peers);
|
||||
} else if (msg.type === 'CONTROL_MODE') {
|
||||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||||
updateHostControlUI({ controlMode: msg.controlMode, amHost: msg.amHost, amController: msg.amController, controllers: msg.controllers, hostPeerId: msg.hostPeerId, hostControlSupported: msg.hostControlSupported, coHostSupported: msg.coHostSupported, inRoom });
|
||||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||||
if (msg.status === 'connected' || msg.status === 'disconnected') {
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
}
|
||||
if (msg.status === 'connected') {
|
||||
clearConnectionErrorTimer();
|
||||
}
|
||||
@@ -1621,6 +1936,7 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (res.peers) updatePeerList(res.peers);
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
updatePingDisplay(res.ping);
|
||||
updateHostControlUI({ controlMode: res.controlMode, amHost: res.amHost, amController: res.amController, controllers: res.controllers, hostPeerId: res.hostPeerId, hostControlSupported: res.hostControlSupported, coHostSupported: res.coHostSupported, inRoom: true });
|
||||
});
|
||||
}
|
||||
if (msg.status === 'disconnected') {
|
||||
@@ -1792,6 +2108,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, '?')}`);
|
||||
@@ -1965,7 +2301,18 @@ function refreshDebugInfo() {
|
||||
addField('Network', `${state.networkState} (${state.networkStateLabel || '?'})`,
|
||||
state.networkState === 1 ? '#22c55e' : state.networkState === 3 ? '#ef4444' : '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));
|
||||
@@ -2095,9 +2442,9 @@ const onboardingSteps = [
|
||||
{
|
||||
icon: '👋',
|
||||
get title() { return getMessage('ONBOARDING_1_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_1_TEXT'); },
|
||||
get text() { return getMessage('ONBOARDING_1_TEXT'); },
|
||||
targetTab: 'tab-room',
|
||||
targetSelector: 'h1'
|
||||
targetSelector: null
|
||||
},
|
||||
{
|
||||
icon: '🏠',
|
||||
@@ -2116,9 +2463,16 @@ const onboardingSteps = [
|
||||
{
|
||||
icon: '⚙️',
|
||||
get title() { return getMessage('ONBOARDING_4_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_4_TEXT'); },
|
||||
get text() { return getMessage('ONBOARDING_4_TEXT'); },
|
||||
targetTab: 'tab-settings',
|
||||
targetSelector: '#username'
|
||||
},
|
||||
{
|
||||
icon: '🍿',
|
||||
get title() { return getMessage('ONBOARDING_5_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_5_TEXT'); },
|
||||
targetTab: 'tab-room',
|
||||
targetSelector: null
|
||||
}
|
||||
];
|
||||
|
||||
@@ -2292,7 +2646,7 @@ function renderOnboardingStep() {
|
||||
}
|
||||
|
||||
onboardingTimeout = setTimeout(() => {
|
||||
const targetEl = document.querySelector(step.targetSelector);
|
||||
const targetEl = step.targetSelector ? document.querySelector(step.targetSelector) : null;
|
||||
if (targetEl && targetEl.offsetParent !== null) {
|
||||
positionSpotlightAndCard(targetEl);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { extractEpisodeId } from './episode-utils.js';
|
||||
|
||||
export const TITLE_PRIVACY_MODES = Object.freeze({
|
||||
FULL: 'full',
|
||||
EPISODE: 'episode',
|
||||
HIDDEN: 'hidden'
|
||||
});
|
||||
|
||||
export function normalizeTitlePrivacyMode(mode) {
|
||||
return Object.values(TITLE_PRIVACY_MODES).includes(mode)
|
||||
? mode
|
||||
: TITLE_PRIVACY_MODES.FULL;
|
||||
}
|
||||
|
||||
export function normalizeSendTabTitle(sendTabTitle, legacyMode = TITLE_PRIVACY_MODES.FULL) {
|
||||
if (typeof sendTabTitle === 'boolean') return sendTabTitle;
|
||||
return normalizeTitlePrivacyMode(legacyMode) === TITLE_PRIVACY_MODES.FULL;
|
||||
}
|
||||
|
||||
export function normalizeTabTitle(title) {
|
||||
if (typeof title !== 'string') return null;
|
||||
const normalized = title.replace(/^\s*(?:\(\d{1,2}\)|\[\d{1,2}\])\s+/, '').trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function sanitizeTabTitle(title, sendTabTitle) {
|
||||
if (!sendTabTitle) return null;
|
||||
return normalizeTabTitle(title);
|
||||
}
|
||||
|
||||
export function sanitizeSharedTitle(title, mode) {
|
||||
const normalizedMode = normalizeTitlePrivacyMode(mode);
|
||||
if (normalizedMode === TITLE_PRIVACY_MODES.HIDDEN) return null;
|
||||
|
||||
if (typeof title !== 'string' || title.length === 0) return null;
|
||||
if (normalizedMode === TITLE_PRIVACY_MODES.EPISODE) {
|
||||
return extractEpisodeId(title) || null;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
export function applyTitlePrivacyToPayload(payload, mode, keys = ['mediaTitle', 'expectedTitle', 'title']) {
|
||||
const source = payload && typeof payload === 'object' ? payload : {};
|
||||
const next = { ...source };
|
||||
keys.forEach(key => {
|
||||
if (Object.prototype.hasOwnProperty.call(next, key)) {
|
||||
next[key] = sanitizeSharedTitle(next[key], mode);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
Generated
+1440
-6
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -1,20 +1,25 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.2.4",
|
||||
"version": "2.5.2",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js",
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "npm run verify",
|
||||
"test:unit": "vitest run",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1"
|
||||
"svgo": "^4.0.1",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
This directory contains utility scripts for the KoalaSync development workflow.
|
||||
|
||||
## build-extension.js
|
||||
## build-extension.cjs
|
||||
|
||||
The primary build tool for KoalaSync. This Node.js script automates two critical tasks:
|
||||
|
||||
@@ -15,7 +15,7 @@ The primary build tool for KoalaSync. This Node.js script automates two critical
|
||||
From the **repository root**, run:
|
||||
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
node scripts/build-extension.cjs
|
||||
```
|
||||
|
||||
### Why this script exists
|
||||
|
||||
@@ -77,7 +77,7 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
const eStart = '// --- SHARED_EVENTS_INJECT_START ---';
|
||||
const eEnd = '// --- SHARED_EVENTS_INJECT_END ---';
|
||||
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.js\n const EVENTS = ${eventsObject};\n ${eEnd}`;
|
||||
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);
|
||||
@@ -97,6 +97,25 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log('✓ Injected shared constants into content.js');
|
||||
} else if (item === 'background.js') {
|
||||
@@ -108,7 +127,7 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
const uPattern = new RegExp(`${uStart}[\\s\\S]+?${uEnd}`);
|
||||
const placeholderUrl = "https://bye.koalastuff.net/c/camp_99ztjRVbK1BNN2RU";
|
||||
|
||||
let uRep = `${uStart}\n // This block is automatically updated by /scripts/build-extension.js\n`;
|
||||
let uRep = `${uStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n`;
|
||||
uRep += ` const UNINSTALL_URL = "${placeholderUrl}";\n`;
|
||||
uRep += ` const BROWSER_TYPE = "${browserName}";\n`;
|
||||
uRep += ` ${uEnd}`;
|
||||
@@ -121,6 +140,12 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
|
||||
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';
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const contentPath = path.join(__dirname, '..', 'extension', 'content.js');
|
||||
const source = fs.readFileSync(contentPath, 'utf8');
|
||||
|
||||
function extractFunction(name, text) {
|
||||
const start = text.indexOf(`function ${name}`);
|
||||
assert.notStrictEqual(start, -1, `${name} not found`);
|
||||
|
||||
const bodyStart = text.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = bodyStart; i < text.length; i++) {
|
||||
if (text[i] === '{') depth++;
|
||||
if (text[i] === '}') depth--;
|
||||
if (depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`${name} body did not terminate`);
|
||||
}
|
||||
|
||||
function 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,
|
||||
tagName: 'VIDEO',
|
||||
videoWidth: width,
|
||||
videoHeight: height,
|
||||
offsetWidth: width,
|
||||
offsetHeight: height,
|
||||
muted: options.muted ?? true,
|
||||
duration: options.duration ?? 0,
|
||||
currentTime: options.currentTime ?? 0,
|
||||
seekable: options.seekable ?? makeSeekable()
|
||||
};
|
||||
}
|
||||
|
||||
const lightPreview = makeVideo('light-preview', 160, 90, { muted: false, duration: 30 });
|
||||
const shadowPlayer = makeVideo('shadow-player', 1920, 1080, { muted: false, duration: 3600 });
|
||||
|
||||
const shadowRoot = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [shadowPlayer];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const shadowHost = { shadowRoot };
|
||||
|
||||
const fakeDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [lightPreview];
|
||||
return [shadowHost];
|
||||
}
|
||||
};
|
||||
|
||||
const fnSource = extractFunction('findVideo', source);
|
||||
const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument);
|
||||
|
||||
const selected = findVideo(fakeDocument);
|
||||
assert.strictEqual(
|
||||
selected,
|
||||
shadowPlayer,
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
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');
|
||||
@@ -1,64 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const contentPath = path.join(__dirname, '..', 'extension', 'content.js');
|
||||
const source = fs.readFileSync(contentPath, 'utf8');
|
||||
|
||||
function extractFunction(name, text) {
|
||||
const start = text.indexOf(`function ${name}`);
|
||||
assert.notStrictEqual(start, -1, `${name} not found`);
|
||||
|
||||
const bodyStart = text.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = bodyStart; i < text.length; i++) {
|
||||
if (text[i] === '{') depth++;
|
||||
if (text[i] === '}') depth--;
|
||||
if (depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`${name} body did not terminate`);
|
||||
}
|
||||
|
||||
function makeVideo(name, width, height, options = {}) {
|
||||
return {
|
||||
name,
|
||||
tagName: 'VIDEO',
|
||||
videoWidth: width,
|
||||
videoHeight: height,
|
||||
offsetWidth: width,
|
||||
offsetHeight: height,
|
||||
muted: options.muted ?? true,
|
||||
duration: options.duration ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const lightPreview = makeVideo('light-preview', 160, 90, { muted: false, duration: 30 });
|
||||
const shadowPlayer = makeVideo('shadow-player', 1920, 1080, { muted: false, duration: 3600 });
|
||||
|
||||
const shadowRoot = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [shadowPlayer];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const shadowHost = { shadowRoot };
|
||||
|
||||
const fakeDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [lightPreview];
|
||||
return [shadowHost];
|
||||
}
|
||||
};
|
||||
|
||||
const fnSource = extractFunction('findVideo', source);
|
||||
const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument);
|
||||
|
||||
const selected = findVideo(fakeDocument);
|
||||
assert.strictEqual(
|
||||
selected,
|
||||
shadowPlayer,
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
console.log('content video finder tests passed');
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractEpisodeId, sameEpisode } from '../extension/episode-utils.js';
|
||||
|
||||
// --- extractEpisodeId ---
|
||||
|
||||
// Standard SxxExx patterns
|
||||
assert.equal(extractEpisodeId('S01E01'), 'S01E01');
|
||||
assert.equal(extractEpisodeId('S1E1'), 'S01E01');
|
||||
assert.equal(extractEpisodeId('s01e01'), 'S01E01', 'case insensitive');
|
||||
assert.equal(extractEpisodeId('Season 1 Episode 2'), 'S01E02');
|
||||
assert.equal(extractEpisodeId('season 01 episode 02'), 'S01E02');
|
||||
|
||||
// Separators: dash, dot, slash, colon, space, comma
|
||||
assert.equal(extractEpisodeId('S01 - E01'), 'S01E01', 'dash separator');
|
||||
assert.equal(extractEpisodeId('S01.E01'), 'S01E01', 'dot separator');
|
||||
assert.equal(extractEpisodeId('S01/E01'), 'S01E01', 'slash separator (Crunchyroll)');
|
||||
assert.equal(extractEpisodeId('S01:E01'), 'S01E01', 'colon separator');
|
||||
assert.equal(extractEpisodeId('S01,E01'), 'S01E01', 'comma separator');
|
||||
assert.equal(extractEpisodeId('S01 E01'), 'S01E01', 'space separator');
|
||||
|
||||
// German / multi-language
|
||||
assert.equal(extractEpisodeId('Folge 5'), 'EP005');
|
||||
assert.equal(extractEpisodeId('Episode 12'), 'EP012');
|
||||
assert.equal(extractEpisodeId('Ep. 3'), 'EP003');
|
||||
assert.equal(extractEpisodeId('#42'), 'EP042');
|
||||
|
||||
// Edge cases
|
||||
assert.equal(extractEpisodeId(null), null);
|
||||
assert.equal(extractEpisodeId(undefined), null);
|
||||
assert.equal(extractEpisodeId(''), null);
|
||||
assert.equal(extractEpisodeId(123), null);
|
||||
assert.equal(extractEpisodeId('Some Movie Title'), null);
|
||||
assert.equal(extractEpisodeId('Breaking Bad'), null);
|
||||
|
||||
// Leading zeros preserved
|
||||
assert.equal(extractEpisodeId('S01E001'), 'S01E001');
|
||||
|
||||
// --- sameEpisode ---
|
||||
|
||||
// Identical episodes
|
||||
assert.equal(sameEpisode('S01E01', 'S01E01'), true);
|
||||
assert.equal(sameEpisode('S01E01 - Pilot', 'S01E01'), true, 'extra text ignored');
|
||||
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German vs English');
|
||||
|
||||
// Different episodes
|
||||
assert.equal(sameEpisode('S01E01', 'S01E02'), false);
|
||||
assert.equal(sameEpisode('Folge 1', 'Folge 2'), false);
|
||||
assert.equal(sameEpisode('S01E01', 'S02E01'), false);
|
||||
|
||||
// Both unknown → assume same (backward compat)
|
||||
assert.equal(sameEpisode(null, null), true);
|
||||
assert.equal(sameEpisode(undefined, undefined), true);
|
||||
assert.equal(sameEpisode('', ''), true);
|
||||
assert.equal(sameEpisode('Some Movie', 'Some Movie'), true);
|
||||
assert.equal(sameEpisode('Some Movie', 'Other Movie'), false, 'different unknowns differ');
|
||||
|
||||
// One unknown, one known → different
|
||||
assert.equal(sameEpisode('S01E01', null), false);
|
||||
assert.equal(sameEpisode(null, 'Episode 5'), false);
|
||||
assert.equal(sameEpisode(undefined, 'S01E01'), false);
|
||||
|
||||
// Mixed formats — only match when the same episode
|
||||
assert.equal(sameEpisode('S01E05', 'S01E05'), true, 'same SxxExx');
|
||||
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German Folge vs English Episode');
|
||||
assert.equal(sameEpisode('Episode 12', 'Ep. 12'), true, 'Episode X vs Ep. X');
|
||||
assert.equal(sameEpisode('#42', 'Folge 42'), true, '#X vs Folge X');
|
||||
|
||||
// Different format IDs → different (season-tagged vs seasonless)
|
||||
assert.equal(sameEpisode('S01E05', 'Episode 5'), false, 'SxxExx vs Episode X: different IDs');
|
||||
assert.equal(sameEpisode('S01E01', 'EP001'), false, 'SxxExx vs EPxxx: different IDs');
|
||||
|
||||
// parseable but truly different
|
||||
assert.equal(sameEpisode('S01E01', 'S01E02'), false, 'different episodes');
|
||||
assert.equal(sameEpisode('S01E01', 'S02E01'), false, 'different seasons');
|
||||
|
||||
console.log('episode-utils tests passed');
|
||||
@@ -128,7 +128,8 @@ const chromeLocalesDir = path.join(__dirname, '..', 'extension', '_locales');
|
||||
const chromeLocaleMap = {
|
||||
'en': 'en', 'de': 'de', 'fr': 'fr', 'es': 'es', 'it': 'it',
|
||||
'ja': 'ja', 'ko': 'ko', 'nl': 'nl', 'pl': 'pl',
|
||||
'pt-BR': 'pt_BR', 'pt': 'pt_PT', 'ru': 'ru', 'tr': 'tr'
|
||||
'pt-BR': 'pt_BR', 'pt': 'pt_PT', 'ru': 'ru', 'tr': 'tr',
|
||||
'zh': 'zh_CN', 'uk': 'uk'
|
||||
};
|
||||
|
||||
// Read SUPPORTED_LANGUAGES from i18n.js
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from '../shared/names.js';
|
||||
|
||||
// --- getAvatarForName (deterministic) ---
|
||||
|
||||
// Exact matches
|
||||
assert.equal(getAvatarForName('Koala'), '🐨', 'Koala');
|
||||
assert.equal(getAvatarForName('Tiger'), '🐯', 'Tiger');
|
||||
assert.equal(getAvatarForName('Panda'), '🐼', 'Panda');
|
||||
assert.equal(getAvatarForName('Fox'), '🦊', 'Fox');
|
||||
|
||||
// Case insensitive
|
||||
assert.equal(getAvatarForName('koala'), '🐨', 'lowercase');
|
||||
assert.equal(getAvatarForName('MyKoalaUser'), '🐨', 'embedded uppercase');
|
||||
|
||||
// Longest match wins (caterpillar > cat)
|
||||
assert.equal(getAvatarForName('CaterpillarCat'), '🐛', 'caterpillar before cat');
|
||||
assert.equal(getAvatarForName('Cat'), '🐱', 'cat alone');
|
||||
|
||||
// Emoji with ZWJ sequences (multi-codepoint)
|
||||
assert.equal(getAvatarForName('Polar'), '🐻\u200D❄️', 'polar bear ZWJ');
|
||||
assert.equal(getAvatarForName('Crow'), '🐦\u200D⬛', 'crow ZWJ');
|
||||
|
||||
// Human-like characters
|
||||
assert.equal(getAvatarForName('Ninja'), '🥷', 'ninja');
|
||||
assert.equal(getAvatarForName('Wizard'), '🧙', 'wizard');
|
||||
assert.equal(getAvatarForName('Pirate'), '🏴', 'pirate');
|
||||
assert.equal(getAvatarForName('Alien'), '👾', 'alien');
|
||||
assert.equal(getAvatarForName('Robot'), '🤖', 'robot');
|
||||
|
||||
// Fallback
|
||||
assert.equal(getAvatarForName(''), '👤', 'empty string');
|
||||
assert.equal(getAvatarForName('Xyzzy123'), '👤', 'unknown name');
|
||||
assert.equal(getAvatarForName(null), '👤', 'null');
|
||||
assert.equal(getAvatarForName(undefined), '👤', 'undefined');
|
||||
|
||||
// --- generateUsername (format check) ---
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const name = generateUsername();
|
||||
// Format: AdjectiveNoun (e.g. "HappyKoala")
|
||||
assert.ok(/^[A-Z][a-z]+[A-Z][a-z]+$/.test(name), `format: ${name}`);
|
||||
// Adjective from list
|
||||
const adj = USERNAME_ADJECTIVES.some(a => name.startsWith(a));
|
||||
assert.ok(adj, `adjective from list: ${name}`);
|
||||
// Noun from list
|
||||
const noun = USERNAME_NOUNS.some(n => name.endsWith(n));
|
||||
assert.ok(noun, `noun from list: ${name}`);
|
||||
}
|
||||
|
||||
// Every noun has an emoji (no broken usernames)
|
||||
for (const noun of USERNAME_NOUNS) {
|
||||
const avatar = getAvatarForName(noun);
|
||||
assert.notEqual(avatar, '👤', `noun "${noun}" has no emoji — add to ANIMAL_EMOJI_MAP`);
|
||||
}
|
||||
|
||||
console.log('names tests passed');
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
clearRateLimitMaps,
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
leaveRoomCounts,
|
||||
rateLimitDenied,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup,
|
||||
CONNECTION_RATE_LIMIT,
|
||||
EVENT_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_LIMIT
|
||||
} from '../server/rate-limiter.js';
|
||||
|
||||
// Helper: mock io for cleanup
|
||||
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, leaveRoom: 0 });
|
||||
stopRateLimitCleanup();
|
||||
}
|
||||
|
||||
// --- checkConnectionRate ---
|
||||
reset();
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed');
|
||||
// Exhaust the rest of the budget (first call above counted as 1).
|
||||
for (let i = 0; i < CONNECTION_RATE_LIMIT - 1; i++) checkConnectionRate('1.1.1.1');
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), false, `connection beyond ${CONNECTION_RATE_LIMIT}/window blocked`);
|
||||
assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented');
|
||||
|
||||
reset();
|
||||
assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent');
|
||||
|
||||
// --- checkEventRate ---
|
||||
reset();
|
||||
assert.equal(checkEventRate('sock1'), true, 'first event allowed');
|
||||
// Exhaust the rest of the budget (first call above counted as 1).
|
||||
for (let i = 0; i < EVENT_RATE_LIMIT - 1; i++) checkEventRate('sock1');
|
||||
assert.equal(checkEventRate('sock1'), false, `event beyond ${EVENT_RATE_LIMIT}/window blocked`);
|
||||
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');
|
||||
for (let i = 0; i < 9; i++) checkHealthRate('1.2.3.4');
|
||||
assert.equal(checkHealthRate('1.2.3.4'), false, '11th health check blocked');
|
||||
assert.equal(rateLimitDenied.health, 1);
|
||||
|
||||
// --- checkAdminMetricsAuthRate ---
|
||||
reset();
|
||||
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), true, 'first admin auth allowed');
|
||||
for (let i = 0; i < 4; i++) checkAdminMetricsAuthRate('5.6.7.8');
|
||||
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), false, '6th admin auth blocked');
|
||||
assert.equal(rateLimitDenied.adminMetricsAuth, 1);
|
||||
|
||||
// --- checkAuthRate ---
|
||||
reset();
|
||||
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), true, 'first auth attempt allowed');
|
||||
for (let i = 0; i < 5; i++) recordAuthFailure('10.0.0.1', 'room-a');
|
||||
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), false, '6th auth attempt blocked');
|
||||
assert.equal(checkAuthRate('10.0.0.1', 'room-b'), true, 'different room not blocked');
|
||||
|
||||
// --- recordAuthFailure ---
|
||||
reset();
|
||||
recordAuthFailure('10.0.0.2', 'room-x');
|
||||
assert.equal(failedAuthAttempts.size, 1, 'failure recorded');
|
||||
const record = failedAuthAttempts.get('10.0.0.2:room-x');
|
||||
assert.equal(record.count, 1, 'count incremented');
|
||||
assert.ok(record.lastAttempt <= Date.now(), 'timestamp set');
|
||||
|
||||
recordAuthFailure('10.0.0.2', 'room-x');
|
||||
assert.equal(failedAuthAttempts.get('10.0.0.2:room-x').count, 2, 'count increments on repeat');
|
||||
|
||||
// --- clearRateLimitMaps ---
|
||||
reset();
|
||||
connectionCounts.set('ip1', { count: 1, resetTime: Date.now() + 60000 });
|
||||
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();
|
||||
startRateLimitCleanup(mockIo);
|
||||
startRateLimitCleanup(mockIo); // double-start guard
|
||||
stopRateLimitCleanup();
|
||||
assert.ok(true, 'cleanup start/stop does not throw');
|
||||
|
||||
// --- rateLimitDenied reset ---
|
||||
reset();
|
||||
rateLimitDenied.connections = 5;
|
||||
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'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { connectionCounts, clearRateLimitMaps } from '../server/rate-limiter.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
|
||||
const WebSocket = require('ws');
|
||||
|
||||
let port, mod, clients = [];
|
||||
|
||||
function wsu() { return `ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=2.4.0&token=62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50`; }
|
||||
async function c() {
|
||||
const ws = new WebSocket(wsu()); clients.push(ws); ws._m = []; ws.on('message', d => ws._m.push(d.toString()));
|
||||
await new Promise((r, j) => { const t = setTimeout(() => j(Error('connect')), 5e3); ws.on('open', () => { clearTimeout(t); r(); }); });
|
||||
ws.send('40'); const s = Date.now(); while (ws._m.length < 2 && Date.now()-s < 5e3) await new Promise(r => setTimeout(r, 50));
|
||||
if (ws._m.length < 2) throw Error('handshake');
|
||||
ws._m.length = 0; return ws;
|
||||
}
|
||||
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
|
||||
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
|
||||
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
|
||||
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
|
||||
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
|
||||
// Test suite opens >10 connections/min — clear the IP connection counter so the
|
||||
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
|
||||
function resetConnectionRate() { connectionCounts.clear(); clearRateLimitMaps(); }
|
||||
|
||||
try {
|
||||
process.env.ADMIN_METRICS_TOKEN = 'ws-integration-test-32chars-minimum!';
|
||||
mod = await import('../server/index.js');
|
||||
await mod.startServer(0,'127.0.0.1');
|
||||
port = mod.httpServer.address().port;
|
||||
|
||||
// --- Pool: 2 peers in 1 room, test everything ---
|
||||
const rid = 't-'+Date.now();
|
||||
const p1 = await c(), p2 = await c();
|
||||
|
||||
// Room + join
|
||||
await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0;
|
||||
|
||||
// Relay
|
||||
s(p1,'play',{currentTime:10}); await w(p2,'play');
|
||||
s(p1,'pause',{currentTime:20}); await w(p2,'pause');
|
||||
s(p1,'seek',{currentTime:30}); await w(p2,'seek');
|
||||
|
||||
// Force Sync
|
||||
s(p1,'force_sync_prepare',{targetTime:0}); await w(p2,'force_sync_prepare');
|
||||
s(p1,'force_sync_ack',{}); await w(p2,'force_sync_ack');
|
||||
s(p1,'force_sync_execute',{}); await w(p2,'force_sync_execute');
|
||||
|
||||
// EVENT_ACK
|
||||
s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack');
|
||||
|
||||
// Lobby
|
||||
s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby');
|
||||
|
||||
// Leave
|
||||
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
|
||||
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
|
||||
const capClient = await c();
|
||||
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
|
||||
const [capEv, capData] = await a(capClient);
|
||||
assert.equal(capEv, 'room_data');
|
||||
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
|
||||
'ROOM_DATA advertises the host-control capability');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Default 'everyone' mode does NOT gate anyone (host-control OFF = unchanged) ---
|
||||
// Confirms that with host-only off, a non-host guest can still drive every
|
||||
// room-moving event exactly like before the feature existed.
|
||||
const erid = 'every-'+Date.now();
|
||||
const e1 = await c(), e2 = await c(); // e1 = creator/host, e2 = guest
|
||||
await j(e1, erid, 'ehost'); await j(e2, erid, 'eguest'); e1._m.length = e2._m.length = 0;
|
||||
s(e2,'play',{currentTime:1}); await w(e1,'play');
|
||||
s(e2,'pause',{currentTime:2}); await w(e1,'pause');
|
||||
s(e2,'seek',{currentTime:3}); await w(e1,'seek');
|
||||
s(e2,'force_sync_prepare',{targetTime:0}); await w(e1,'force_sync_prepare');
|
||||
s(e2,'episode_lobby',{expectedTitle:'S1E1'}); await w(e1,'episode_lobby');
|
||||
// (reaching here without a wait timeout == nothing was gated)
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Host Control Mode ---
|
||||
const hrid = 'host-'+Date.now();
|
||||
const h1 = await c(), h2 = await c(); // h1 = host (first joiner), h2 = guest
|
||||
await j(h1, hrid, 'host1'); await j(h2, hrid, 'guest1'); h1._m.length = h2._m.length = 0;
|
||||
|
||||
// Host enables host-only -> both peers get the control_mode broadcast
|
||||
s(h1,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(h1,'control_mode'); await w(h2,'control_mode');
|
||||
h1._m.length = h2._m.length = 0;
|
||||
|
||||
// Guest's room-moving event (pause) is dropped -> host must NOT receive it
|
||||
s(h2,'pause',{currentTime:5});
|
||||
let guestPauseDropped = false; try { await w(h1,'pause',600); } catch { guestPauseDropped = true; }
|
||||
assert.ok(guestPauseDropped, 'guest pause dropped in host-only');
|
||||
|
||||
// Host's own pause still relays to the guest
|
||||
s(h1,'pause',{currentTime:7}); await w(h2,'pause');
|
||||
h1._m.length = h2._m.length = 0;
|
||||
|
||||
// desynced flag is relayed through PEER_STATUS heartbeats so the host's UI
|
||||
// can show "Solo" for guests watching on their own.
|
||||
s(h2,'peer_status',{status:'heartbeat',desynced:true,currentTime:42,playbackState:'playing'});
|
||||
let hbData = null; const hbStart = Date.now();
|
||||
while (Date.now()-hbStart < 600 && !hbData) {
|
||||
for (let i=0;i<h1._m.length;i++){ const r=h1._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ h1._m.splice(i,1); hbData=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(hbData && hbData.desynced === true, 'desynced=true relayed in heartbeat');
|
||||
h1._m.length = h2._m.length = 0;
|
||||
|
||||
// Guest cannot change the control mode -> host must NOT receive a broadcast.
|
||||
// The rejected sender gets a unicast of the *actual* state so any optimistic
|
||||
// UI reverts (H-5); assert both halves.
|
||||
s(h2,'set_control_mode',{controlMode:'everyone'});
|
||||
let guestSetBlocked = false; try { await w(h1,'control_mode',600); } catch { guestSetBlocked = true; }
|
||||
assert.ok(guestSetBlocked, 'non-host cannot set control mode');
|
||||
let rejectSync = null; const rsStart = Date.now();
|
||||
while (Date.now()-rsStart < 600 && !rejectSync) {
|
||||
for (let i=0;i<h2._m.length;i++){ const r=h2._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ h2._m.splice(i,1); rejectSync=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(rejectSync && rejectSync.controlMode==='host-only' && rejectSync.hostPeerId==='host1',
|
||||
'rejected sender is re-synced to actual state');
|
||||
h1._m.length = h2._m.length = 0;
|
||||
|
||||
// Host leaves -> room falls back to 'everyone' and reassigns host to the guest
|
||||
s(h1,'leave_room',{});
|
||||
let fb = null; const fbStart = Date.now();
|
||||
while (Date.now()-fbStart < 2000 && !fb) {
|
||||
for (let i=0;i<h2._m.length;i++){ const r=h2._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ h2._m.splice(i,1); fb=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,50));
|
||||
}
|
||||
assert.ok(fb && fb.controlMode==='everyone' && fb.hostPeerId==='guest1', 'host leave -> fallback everyone + new host');
|
||||
close();
|
||||
|
||||
// --- M-4: rapid control-mode toggles are debounced per-room ---
|
||||
const drid = 'debounce-'+Date.now();
|
||||
const db1 = await c(), db2 = await c();
|
||||
await j(db1, drid, 'dhost'); await j(db2, drid, 'dguest'); db1._m.length = db2._m.length = 0;
|
||||
|
||||
// First toggle (everyone → host-only) goes through.
|
||||
s(db1,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(db1,'control_mode'); await w(db2,'control_mode');
|
||||
db1._m.length = db2._m.length = 0;
|
||||
|
||||
// Immediate second toggle (host-only → everyone) should be debounced:
|
||||
// broadcast goes to neither peer, but sender gets a re-sync unicast.
|
||||
s(db1,'set_control_mode',{controlMode:'everyone'});
|
||||
let dGuestGotIt = false; try { await w(db2,'control_mode',600); } catch { dGuestGotIt = true; }
|
||||
assert.ok(dGuestGotIt, 'rapid control-mode toggle is debounced (no broadcast)');
|
||||
let dSenderResync = null; const dsStart = Date.now();
|
||||
while (Date.now()-dsStart < 600 && !dSenderResync) {
|
||||
for (let i=0;i<db1._m.length;i++){ const r=db1._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ db1._m.splice(i,1); dSenderResync=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(dSenderResync && dSenderResync.controlMode==='host-only',
|
||||
'debounced toggle re-syncs sender to actual state');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Host role survives peerId dedup (reconnect / second tab) ---
|
||||
const hdrid = 'dedup-host-'+Date.now();
|
||||
const hd1 = await c(), hd2 = await c();
|
||||
await j(hd1, hdrid, 'dhost'); await j(hd2, hdrid, 'dguest'); hd1._m.length = hd2._m.length = 0;
|
||||
s(hd1,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(hd1,'control_mode'); await w(hd2,'control_mode');
|
||||
hd1._m.length = hd2._m.length = 0;
|
||||
// The host's peerId re-joins on a fresh socket → server dedupes the old socket.
|
||||
// This must NOT demote the host or reset the mode (a network blip / second tab).
|
||||
const hd3 = await c();
|
||||
s(hd3,'join_room',{roomId:hdrid,peerId:'dhost',protocolVersion:'1.0.0'});
|
||||
const hdrd = await a(hd3);
|
||||
assert.equal(hdrd[0],'room_data');
|
||||
assert.ok(hdrd[1].controlMode === 'host-only' && hdrd[1].hostPeerId === 'dhost',
|
||||
'host role + host-only mode survive peerId dedup (reconnect/second tab)');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Co-Host: owner promotes a guest to controller (can drive); demote re-gates ---
|
||||
const crid = 'cohost-'+Date.now();
|
||||
const co1 = await c(), co2 = await c(), co3 = await c(); // owner / to-promote / stays guest
|
||||
await j(co1, crid, 'owner'); await j(co2, crid, 'cohost'); await j(co3, crid, 'guestx');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
s(co1,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(co1,'control_mode'); await w(co2,'control_mode'); await w(co3,'control_mode');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
// before promotion the co-host is gated
|
||||
s(co2,'pause',{currentTime:1});
|
||||
let coGatedBefore=false; try { await w(co1,'pause',500); } catch { coGatedBefore=true; }
|
||||
assert.ok(coGatedBefore, 'co-host gated before promotion');
|
||||
// owner promotes co-host → controllers broadcast includes owner + cohost
|
||||
s(co1,'set_peer_role',{peerId:'cohost',controller:true});
|
||||
let promo=null; const pps=Date.now();
|
||||
while(Date.now()-pps<800 && !promo){ for(let i=0;i<co2._m.length;i++){const r=co2._m[i];if(r.startsWith('42')){const[e,dd]=JSON.parse(r.substring(2));if(e==='control_mode'){co2._m.splice(i,1);promo=dd;break;}}} await new Promise(r=>setTimeout(r,30)); }
|
||||
assert.ok(promo && Array.isArray(promo.controllers) && promo.controllers.includes('cohost') && promo.controllers.includes('owner'),
|
||||
'promotion broadcasts controllers (owner + cohost)');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
// promoted co-host can now drive; a plain guest still cannot
|
||||
s(co2,'pause',{currentTime:2}); await w(co1,'pause');
|
||||
s(co3,'play',{currentTime:3});
|
||||
let plainGuestGated=false; try { await w(co1,'play',500); } catch { plainGuestGated=true; }
|
||||
assert.ok(plainGuestGated, 'plain guest still gated after a co-host is promoted');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
// a non-owner (the co-host) cannot promote anyone
|
||||
s(co2,'set_peer_role',{peerId:'guestx',controller:true});
|
||||
let nonOwnerBlocked=false; try { await w(co3,'control_mode',500); } catch { nonOwnerBlocked=true; }
|
||||
assert.ok(nonOwnerBlocked, 'non-owner cannot promote (no room broadcast)');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
// owner demotes the co-host → gated again
|
||||
s(co1,'set_peer_role',{peerId:'cohost',controller:false});
|
||||
await w(co2,'control_mode');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
s(co2,'seek',{currentTime:4});
|
||||
let coGatedAfter=false; try { await w(co1,'seek',500); } catch { coGatedAfter=true; }
|
||||
assert.ok(coGatedAfter, 'demoted co-host is gated again');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- H-1: a demoted co-host's FORCE_SYNC_EXECUTE still relays when they
|
||||
// initiated the in-flight PREPARE. Without the initiator exemption, the
|
||||
// EXECUTE would be dropped by the host-only gate and every peer would be
|
||||
// left stuck paused. ---
|
||||
const h1rid = 'h1-'+Date.now();
|
||||
const ho = await c(), hc = await c(), hg = await c(); // owner / co-host / guest
|
||||
await j(ho, h1rid, 'owner'); await j(hc, h1rid, 'cohost'); await j(hg, h1rid, 'guest');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
s(ho,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(ho,'control_mode'); await w(hc,'control_mode'); await w(hg,'control_mode');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
// owner promotes co-host; co-host initiates force sync
|
||||
s(ho,'set_peer_role',{peerId:'cohost',controller:true});
|
||||
await w(hc,'control_mode');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
s(hc,'force_sync_prepare',{targetTime:0});
|
||||
await w(ho,'force_sync_prepare'); await w(hg,'force_sync_prepare');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
// owner demotes the co-host mid-flight — the EXECUTE must still go through.
|
||||
// Wait out the per-room role-change debounce (M-4) so this demote broadcasts:
|
||||
// in real usage the host can't promote, run a force-sync, and demote inside 500ms.
|
||||
await new Promise(r => setTimeout(r, 550));
|
||||
s(ho,'set_peer_role',{peerId:'cohost',controller:false});
|
||||
await w(hc,'control_mode');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
s(hc,'force_sync_execute',{});
|
||||
await w(ho,'force_sync_execute'); await w(hg,'force_sync_execute');
|
||||
// After the EXECUTE, the initiator slot is cleared: a fresh EXECUTE from the
|
||||
// (now plain guest) co-host is gated again, confirming the exemption is scoped.
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
s(hc,'force_sync_execute',{});
|
||||
let reGated=false; try { await w(ho,'force_sync_execute',500); } catch { reGated=true; }
|
||||
assert.ok(reGated, 'initiator exemption is cleared after the EXECUTE relayes');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- A guest's stray EXECUTE (no matching PREPARE they initiated) is still gated ---
|
||||
const grid = 'h1b-'+Date.now();
|
||||
const go = await c(), gg = await c();
|
||||
await j(go, grid, 'own'); await j(gg, grid, 'gst');
|
||||
go._m.length = gg._m.length = 0;
|
||||
s(go,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(go,'control_mode'); await w(gg,'control_mode');
|
||||
go._m.length = gg._m.length = 0;
|
||||
s(gg,'force_sync_execute',{});
|
||||
let uninitGated=false; try { await w(go,'force_sync_execute',500); } catch { uninitGated=true; }
|
||||
assert.ok(uninitGated, 'guest FORCE_SYNC_EXECUTE without a matching PREPARE is gated');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// =====================================================================
|
||||
// BACKWARD COMPATIBILITY — old clients (pre-HCM build) against new server.
|
||||
// These tests simulate an old client by deliberately omitting fields the
|
||||
// new feature added (desynced in heartbeats, capabilities expectation)
|
||||
// and by ignoring CONTROL_MODE broadcasts. The wire format for existing
|
||||
// events must stay byte-compatible: the relay must accept old payloads
|
||||
// and must not inject new fields old clients would misread.
|
||||
// =====================================================================
|
||||
|
||||
// --- BC-1: Old-client heartbeat (no `desynced` field) is accepted and
|
||||
// relayed without injecting `desynced`. Old clients never sent the
|
||||
// field; the relay must strip it from the wire so we don't surprise
|
||||
// them with unexpected keys. ---
|
||||
const bcrid = 'bc-'+Date.now();
|
||||
const bco = await c(), bcn = await c(); // bco = "old", bcn = "new"
|
||||
await j(bco, bcrid, 'oldp'); await j(bcn, bcrid, 'newp');
|
||||
bco._m.length = bcn._m.length = 0;
|
||||
// Old-client heartbeat: every field an old build would send, NO `desynced`.
|
||||
s(bco,'peer_status',{
|
||||
status:'heartbeat', username:'old', tabTitle:'t', mediaTitle:'m',
|
||||
playbackState:'playing', currentTime:42, volume:0.5, muted:false
|
||||
});
|
||||
let bcRelay = null; const bcStart = Date.now();
|
||||
while (Date.now()-bcStart < 800 && !bcRelay) {
|
||||
for (let i=0;i<bcn._m.length;i++){ const r=bcn._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ bcn._m.splice(i,1); bcRelay=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(bcRelay, 'old-style heartbeat relayed');
|
||||
assert.ok(bcRelay.desynced === undefined, 'old-client heartbeat has no desynced on the wire (stripped)');
|
||||
assert.equal(bcRelay.currentTime, 42, 'old-client currentTime preserved');
|
||||
assert.equal(bcRelay.senderId, 'oldp', 'old-client senderId preserved');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- BC-2: Old client in a host-only room — server still gates its events
|
||||
// even though the client has no awareness of the mode. This is the
|
||||
// key guarantee for mixed rooms during rollout: an old client can't
|
||||
// drive a host-only room just because it ignores CONTROL_MODE. ---
|
||||
const hmrid = 'hcmix-'+Date.now();
|
||||
const hmo = await c(), hmg = await c(); // hmo = host (new), hmg = "old" guest
|
||||
await j(hmo, hmrid, 'hmixhost'); await j(hmg, hmrid, 'hmixold');
|
||||
hmo._m.length = hmg._m.length = 0;
|
||||
s(hmo,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(hmo,'control_mode'); await w(hmg,'control_mode'); // old client's socket still receives it; old client would ignore
|
||||
hmo._m.length = hmg._m.length = 0;
|
||||
// "Old" guest tries to drive — server must drop. Host must NOT receive it.
|
||||
s(hmg,'pause',{currentTime:5});
|
||||
let oldGated=false; try { await w(hmo,'pause',600); } catch { oldGated=true; }
|
||||
assert.ok(oldGated, 'old-client pause dropped in host-only (server enforces regardless of client awareness)');
|
||||
// Host's own command still relays to the old client's socket — old client
|
||||
// applies it via its existing PLAY/PAUSE handler.
|
||||
s(hmo,'pause',{currentTime:7}); await w(hmg,'pause');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- BC-3: Mixed room with old + new client in 'everyone' mode — every
|
||||
// event flows identically to pre-HCM. Confirms no regression in the
|
||||
// default-mode relay path that could fragment a rolling-update room. ---
|
||||
const mxrid = 'mix-'+Date.now();
|
||||
const mxo = await c(), mxn = await c(); // mxo = old, mxn = new
|
||||
await j(mxo, mxrid, 'oldmx'); await j(mxn, mxrid, 'newmx');
|
||||
mxo._m.length = mxn._m.length = 0;
|
||||
// Old → new
|
||||
s(mxo,'play',{currentTime:1}); await w(mxn,'play');
|
||||
s(mxo,'seek',{currentTime:99}); await w(mxn,'seek');
|
||||
s(mxo,'force_sync_prepare',{targetTime:5}); await w(mxn,'force_sync_prepare');
|
||||
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
|
||||
// New → old
|
||||
mxo._m.length = mxn._m.length = 0;
|
||||
s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
|
||||
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
|
||||
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
|
||||
s(mxn,'episode_lobby_cancel',{}); await w(mxo,'episode_lobby_cancel');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- BC-4: New-client heartbeat WITH `desynced` does not break an old
|
||||
// client's receive path. The field is appended but old clients ignore
|
||||
// unknown keys — verify the relay preserves every pre-HCM field and
|
||||
// only adds `desynced`. ---
|
||||
const b4rid = 'bc4-'+Date.now();
|
||||
const b4old = await c(), b4new = await c();
|
||||
await j(b4old, b4rid, 'b4o'); await j(b4new, b4rid, 'b4n');
|
||||
b4old._m.length = b4new._m.length = 0;
|
||||
s(b4new,'peer_status',{status:'heartbeat', desynced:true, currentTime:7, playbackState:'paused', username:'newp'});
|
||||
let b4Relay = null; const b4Start = Date.now();
|
||||
while (Date.now()-b4Start < 800 && !b4Relay) {
|
||||
for (let i=0;i<b4old._m.length;i++){ const r=b4old._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ b4old._m.splice(i,1); b4Relay=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(b4Relay, 'new-client heartbeat relayed to old client');
|
||||
assert.equal(b4Relay.desynced, true, 'desynced preserved for new recipients');
|
||||
assert.equal(b4Relay.currentTime, 7, 'old fields preserved on the same relay');
|
||||
assert.equal(b4Relay.senderId, 'b4n', 'senderId preserved');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Password room ---
|
||||
const prid = 'pw-'+Date.now();
|
||||
const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret');
|
||||
const pw2 = await c();
|
||||
s(pw2,'join_room',{roomId:prid,password:'BAD',peerId:'bad',protocolVersion:'1.0.0'});
|
||||
assert.equal((await a(pw2))[0],'error','wrong pw');
|
||||
const pw3 = await c();
|
||||
s(pw3,'join_room',{roomId:prid,password:'s3cret',peerId:'good',protocolVersion:'1.0.0'});
|
||||
assert.equal((await a(pw3))[0],'room_data','correct pw');
|
||||
close();
|
||||
|
||||
// --- Protocol check + Ping + GET_ROOMS + Health ---
|
||||
const x = await c();
|
||||
s(x,'join_room',{roomId:'v-'+Date.now(),peerId:'old',protocolVersion:'0.0.1'});
|
||||
await w(x,'error'); // version mismatch
|
||||
x._m.length = 0;
|
||||
s(x,'ping',{t:Date.now()}); await w(x,'pong');
|
||||
await j(x,'lst-'+Date.now(),'l1');
|
||||
x._m.length = 0;
|
||||
s(x,'get_rooms',{}); await w(x,'room_list');
|
||||
close();
|
||||
|
||||
// Dedup
|
||||
const did = 'dup-'+Date.now();
|
||||
const d1 = await c(), d2 = await c();
|
||||
await j(d1, did, 'dup'); d1._m.length = 0;
|
||||
s(d2,'join_room',{roomId:did,peerId:'dup',protocolVersion:'1.0.0'});
|
||||
assert.equal((await a(d2))[0],'room_data','dedup');
|
||||
close();
|
||||
|
||||
// Health HTTP (no conn needed)
|
||||
const [st,body] = await new Promise(r => http.get(`http://127.0.0.1:${port}/`, res => {
|
||||
let d=''; res.on('data',c=>d+=c); res.on('end',()=>r([res.statusCode,JSON.parse(d)])); }));
|
||||
assert.equal(st,200); assert.equal(body.status,'online');
|
||||
|
||||
console.log('All WebSocket integration tests passed (incl. host control mode)');
|
||||
} catch(e) {
|
||||
console.error('FAILED:', e.message);
|
||||
process.exitCode=1;
|
||||
} finally {
|
||||
close();
|
||||
if (mod?.stopServerForTests) await mod.stopServerForTests();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TITLE_PRIVACY_MODES,
|
||||
applyTitlePrivacyToPayload,
|
||||
normalizeSendTabTitle,
|
||||
normalizeTabTitle,
|
||||
normalizeTitlePrivacyMode,
|
||||
sanitizeSharedTitle,
|
||||
sanitizeTabTitle
|
||||
} from '../extension/title-privacy.js';
|
||||
|
||||
assert.equal(normalizeTitlePrivacyMode(undefined), TITLE_PRIVACY_MODES.FULL);
|
||||
assert.equal(normalizeTitlePrivacyMode('unknown'), TITLE_PRIVACY_MODES.FULL);
|
||||
assert.equal(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN), TITLE_PRIVACY_MODES.HIDDEN);
|
||||
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('(500) Days of Summer'), '(500) 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);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Example Movie', 'full'), 'Example Movie');
|
||||
assert.equal(sanitizeSharedTitle('', 'full'), null);
|
||||
assert.equal(sanitizeSharedTitle(null, 'full'), null);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode'), 'S01E04');
|
||||
assert.equal(sanitizeSharedTitle('Folge 7 - Private Server', 'episode'), 'EP007');
|
||||
assert.equal(sanitizeSharedTitle('Example Movie', 'episode'), null);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Show Name - S01E04', 'hidden'), null);
|
||||
assert.equal(sanitizeSharedTitle('Private Tab Title', 'hidden'), null);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
mediaTitle: 'Show Name - S01E04',
|
||||
currentTime: 42
|
||||
}, 'episode'),
|
||||
{
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
mediaTitle: 'S01E04',
|
||||
currentTime: 42
|
||||
},
|
||||
'media privacy must not rewrite tabTitle'
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
status: 'heartbeat'
|
||||
}, 'episode'),
|
||||
{
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
status: 'heartbeat'
|
||||
},
|
||||
'media privacy must not rewrite tabTitle or add absent media keys'
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: 'Private Media',
|
||||
expectedTitle: 'S01E04',
|
||||
title: 'S01E04'
|
||||
}, 'hidden'),
|
||||
{
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: null,
|
||||
expectedTitle: null,
|
||||
title: null
|
||||
},
|
||||
'hidden media privacy must not clear tabTitle'
|
||||
);
|
||||
|
||||
console.log('title-privacy tests passed');
|
||||
@@ -7,25 +7,32 @@ 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' }
|
||||
}],
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.js']],
|
||||
['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']],
|
||||
['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']],
|
||||
['title privacy unit tests', 'node', ['scripts/test-title-privacy.mjs']],
|
||||
['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']],
|
||||
['names generator', 'node', ['scripts/test-names.mjs']],
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
|
||||
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
|
||||
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
|
||||
['server syntax index', 'node', ['-c', 'server/index.js']],
|
||||
['server syntax ops', 'node', ['-c', 'server/ops.js']],
|
||||
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
|
||||
['content syntax', 'node', ['-c', 'extension/content.js']],
|
||||
['popup syntax', 'node', ['-c', 'extension/popup.js']],
|
||||
['background syntax', 'node', ['-c', 'extension/background.js']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.js']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.cjs']],
|
||||
['website locale coverage', 'node', ['scripts/test-website-locales.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']],
|
||||
['website build', 'node', ['website/build.js']]
|
||||
['website build', 'node', ['website/build.cjs']]
|
||||
];
|
||||
|
||||
function runCheck([label, command, args, options = {}]) {
|
||||
|
||||
@@ -71,3 +71,11 @@ npm start
|
||||
- **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.
|
||||
|
||||
## Module Structure
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.js` | Express + Socket.IO server: room management, relay loop, graceful shutdown |
|
||||
| `rate-limiter.js` | Connection, event, health, and auth rate limiting with 6 functions + cleanup intervals |
|
||||
| `ops.js` | Health endpoint helpers, metrics payload builder, auth validation |
|
||||
|
||||
+333
-194
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
||||
import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
@@ -12,6 +12,40 @@ import {
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from './ops.js';
|
||||
import {
|
||||
ROOM_LIST_COOLDOWN_MS,
|
||||
HEALTH_RATE_LIMIT_PER_MINUTE,
|
||||
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
leaveRoomCounts,
|
||||
rateLimitDenied,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup,
|
||||
clearRateLimitMaps
|
||||
} from './rate-limiter.js';
|
||||
|
||||
// Re-export for external consumers (tests, ops)
|
||||
export {
|
||||
HEALTH_RATE_LIMIT_PER_MINUTE,
|
||||
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
connectionCounts,
|
||||
eventCounts,
|
||||
rateLimitDenied
|
||||
};
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -26,15 +60,16 @@ const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
|
||||
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25;
|
||||
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
|
||||
const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || '';
|
||||
const ROOM_LIST_COOLDOWN_MS = 10000;
|
||||
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
|
||||
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
|
||||
const HEALTH_RESPONSE_CACHE_TTL_MS = 60000;
|
||||
|
||||
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
|
||||
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
|
||||
}
|
||||
|
||||
if (!process.env.SERVER_SALT) {
|
||||
console.warn('[SECURITY] SERVER_SALT is not set — using the built-in default salt (public in the repo). Set a unique SERVER_SALT so room-password hashes are not computed with a known salt.');
|
||||
}
|
||||
|
||||
export const app = express();
|
||||
app.set('trust proxy', 1); // For real client IP through reverse proxy
|
||||
|
||||
@@ -81,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
|
||||
})
|
||||
@@ -108,6 +144,8 @@ export const io = new Server(httpServer, {
|
||||
allowUpgrades: false
|
||||
});
|
||||
|
||||
startRateLimitCleanup(io);
|
||||
|
||||
/**
|
||||
* In-memory storage
|
||||
*/
|
||||
@@ -115,167 +153,50 @@ export const rooms = new Map();
|
||||
const socketToRoom = new Map();
|
||||
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
||||
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
|
||||
const peerJoinLocks = new Map(); // peerId -> Promise (prevents race on same peerId joins)
|
||||
|
||||
// Host Control Mode: events a non-host guest may NOT initiate while a room is in
|
||||
// 'host-only' mode (they would move/disrupt everyone). Reactions like FORCE_SYNC_ACK,
|
||||
// EPISODE_READY, PEER_STATUS heartbeats remain allowed for all peers.
|
||||
const HOST_ONLY_GATED_EVENTS = new Set([
|
||||
EVENTS.PLAY,
|
||||
EVENTS.PAUSE,
|
||||
EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY,
|
||||
EVENTS.EPISODE_LOBBY_CANCEL
|
||||
]);
|
||||
|
||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST];
|
||||
|
||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||
// from generating one broadcast per toggle across all peers.
|
||||
const CONTROL_MODE_MIN_INTERVAL_MS = 500;
|
||||
|
||||
// Canonical control-mode/role snapshot sent to clients. controllers always includes
|
||||
// the owner (hostPeerId). Built in one place so every emit stays consistent.
|
||||
function controlModePayload(room) {
|
||||
return {
|
||||
controlMode: room.controlMode,
|
||||
hostPeerId: room.hostPeerId,
|
||||
controllers: room.controllers ? Array.from(room.controllers) : (room.hostPeerId ? [room.hostPeerId] : [])
|
||||
};
|
||||
}
|
||||
|
||||
function log(type, message, details = '') {
|
||||
const debugLogging = process.env.DEBUG_LOGGING === '1';
|
||||
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS';
|
||||
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS' || type === 'ACKDROP';
|
||||
if (!debugLogging && isVerbose) return;
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] [${type}] ${message}`, details);
|
||||
}
|
||||
|
||||
// Rate Limiting & Security
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
|
||||
function checkAuthRate(ip, roomId) {
|
||||
const key = `${ip}:${roomId}`;
|
||||
const now = Date.now();
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
|
||||
// Block for 15 mins if 5 fails in 2 mins
|
||||
if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reset if last attempt was long ago
|
||||
if ((now - record.lastAttempt) > 2 * 60 * 1000) {
|
||||
record.count = 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
const now = Date.now();
|
||||
// 1. Clear expired entries (> 15 mins)
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If still over 200k, perform LRU-style eviction on the oldest 10,000 entries (first items in Map order)
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.');
|
||||
for (const [key] of failedAuthAttempts.entries()) {
|
||||
if (failedAuthAttempts.size <= 190000) {
|
||||
break;
|
||||
}
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = `${ip}:${roomId}`;
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
record.count++;
|
||||
record.lastAttempt = Date.now();
|
||||
failedAuthAttempts.delete(key); // Remove first to update insertion order (moves key to the end of iteration)
|
||||
failedAuthAttempts.set(key, record);
|
||||
}
|
||||
|
||||
// Periodically clean up old auth failure records (every 15 minutes)
|
||||
const authFailureCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
|
||||
// Actual rate-limit denial counters (incremented only when a request is denied)
|
||||
const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0
|
||||
};
|
||||
|
||||
// Clean up connection counts and event counts to prevent memory leak
|
||||
const rateLimitCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
connectionCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of eventCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
healthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of adminMetricsAuthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
adminMetricsAuthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [socketId] of roomListCooldowns.entries()) {
|
||||
if (!io.sockets.sockets.has(socketId)) {
|
||||
roomListCooldowns.delete(socketId);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function checkConnectionRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
connectionCounts.set(ip, entry);
|
||||
if (entry.count <= 10) return true;
|
||||
rateLimitDenied.connections++;
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkEventRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
|
||||
entry.count++;
|
||||
eventCounts.set(socketId, entry);
|
||||
if (entry.count <= 30) return true;
|
||||
rateLimitDenied.events++;
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
rateLimitDenied.health++;
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkAdminMetricsAuthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
adminMetricsAuthCounts.set(ip, entry);
|
||||
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
rateLimitDenied.adminMetricsAuth++;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central peer teardown. Removes a socket from all room state and notifies
|
||||
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
|
||||
@@ -320,6 +241,42 @@ function removePeerFromRoom(socketId, roomId, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3.6. Host Control Mode: if the host left (and isn't still connected via another
|
||||
// socket), fall back to 'everyone' so the room never gets stuck locked, and
|
||||
// reassign host to the earliest remaining peer so the feature stays usable.
|
||||
// (v1: immediate fallback, no grace period — see host-control-mode docs.)
|
||||
// Skip while a join for this peerId is in flight (peerJoinLocks holds it):
|
||||
// that's the reconnect / second-tab case where the same peerId is being
|
||||
// re-added right after the dedupe kicked the old socket. Demoting there
|
||||
// would silently unlock the room on every second-tab open.
|
||||
// NOTE: this does *not* cover a true network blip where the old socket's
|
||||
// 'disconnect' fires before the new socket acquires its join lock — in that
|
||||
// window peerJoinLocks is empty, so the fallback fires. Documented v1
|
||||
// limitation (no host grace period); see KNOWN_LIMITATIONS.md.
|
||||
const peerRejoining = peerJoinLocks.has(peerId);
|
||||
const peerGone = !isPeerStillConnected && !peerRejoining;
|
||||
if (peerGone && room.controllers && room.peers.size > 0) {
|
||||
const wasController = room.controllers.has(peerId);
|
||||
room.controllers.delete(peerId);
|
||||
// H-1: a leaving initiator strands the room's force-sync — release the
|
||||
// slot so a future controller's PREPARE can take over cleanly.
|
||||
if (room.forceSyncInitiator === peerId) room.forceSyncInitiator = null;
|
||||
if (room.hostPeerId === peerId) {
|
||||
// Owner left → reassign owner + fall back to 'everyone' so the room is
|
||||
// never stuck locked, and reset the controller set to just the new owner.
|
||||
const nextPeerData = room.peerData.values().next().value;
|
||||
room.hostPeerId = nextPeerData ? nextPeerData.peerId : null;
|
||||
room.controlMode = CONTROL_MODES.EVERYONE;
|
||||
room.controllers = new Set(room.hostPeerId ? [room.hostPeerId] : []);
|
||||
io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Owner left room ${roomId.substring(0, 3)}*** — fell back to 'everyone', new owner: ${room.hostPeerId}`);
|
||||
} else if (wasController) {
|
||||
// A co-host left → keep the mode, just broadcast the updated controller list.
|
||||
io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Controller ${peerId} left room ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete empty room
|
||||
if (room.peers.size === 0) {
|
||||
rooms.delete(roomId);
|
||||
@@ -452,7 +409,19 @@ io.on('connection', (socket) => {
|
||||
peers: new Set(),
|
||||
peerIds: new Map(),
|
||||
peerData: new Map(),
|
||||
lastActivity: Date.now()
|
||||
lastActivity: Date.now(),
|
||||
// Host Control Mode: creator (first joiner) is the host/owner.
|
||||
hostPeerId: peerId,
|
||||
controlMode: CONTROL_MODES.EVERYONE,
|
||||
lastControlModeChangeAt: 0, // M-4: per-room debounce for control-mode toggles
|
||||
lastRoleChangeAt: 0, // M-4: per-room debounce for role promote/demote
|
||||
// Co-Host: peers allowed to drive in 'host-only'. Always includes the owner.
|
||||
controllers: new Set([peerId]),
|
||||
// H-1: peerId of the in-flight force-sync initiator. Lets a demoted
|
||||
// controller's FORCE_SYNC_EXECUTE through the host-only gate — without
|
||||
// it, demoting a co-host mid-force-sync would drop their EXECUTE and
|
||||
// leave every peer stuck paused.
|
||||
forceSyncInitiator: null
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
createdByMe = true;
|
||||
@@ -469,7 +438,20 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdByMe) {
|
||||
let peerLockPromise = peerJoinLocks.get(peerId);
|
||||
if (peerLockPromise) {
|
||||
await peerLockPromise;
|
||||
room = rooms.get(roomId);
|
||||
if (!room) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Room no longer exists" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
let resolvePeerLock;
|
||||
peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; });
|
||||
peerJoinLocks.set(peerId, peerLockPromise);
|
||||
try {
|
||||
if (!createdByMe) {
|
||||
if (room.passwordHash) {
|
||||
if (!password || hashPassword(password) !== room.passwordHash) {
|
||||
recordAuthFailure(ip, roomId);
|
||||
@@ -521,12 +503,20 @@ io.on('connection', (socket) => {
|
||||
peerToSocket.set(peerId, socket.id);
|
||||
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
|
||||
socket.emit(EVENTS.ROOM_DATA, {
|
||||
roomId,
|
||||
socket.emit(EVENTS.ROOM_DATA, {
|
||||
roomId,
|
||||
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
|
||||
activeLobby: room.activeLobby || null
|
||||
activeLobby: room.activeLobby || null,
|
||||
hostPeerId: room.hostPeerId || null,
|
||||
controlMode: room.controlMode || CONTROL_MODES.EVERYONE,
|
||||
controllers: room.controllers ? Array.from(room.controllers) : [],
|
||||
capabilities: SERVER_CAPABILITIES
|
||||
});
|
||||
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
|
||||
} finally {
|
||||
peerJoinLocks.delete(peerId);
|
||||
resolvePeerLock();
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', `Join error for ${socket.id}`, err);
|
||||
if (socket.connected) {
|
||||
@@ -560,7 +550,35 @@ io.on('connection', (socket) => {
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (room) {
|
||||
room.lastActivity = Date.now();
|
||||
|
||||
|
||||
// --- Host Control Mode gate ---
|
||||
// In 'host-only' mode, drop room-moving events from anyone who is not
|
||||
// a controller (the owner + any promoted co-hosts). Robust chokepoint:
|
||||
// independent of client behavior, kills spam. Heartbeats/ACKs pass.
|
||||
//
|
||||
// H-1 exception: a demoted co-host's FORCE_SYNC_EXECUTE still has to
|
||||
// land — otherwise their already-relayed PREPARE would leave the whole
|
||||
// room stuck paused. Track the in-flight initiator on PREPARE and let
|
||||
// their matching EXECUTE through regardless of current controllers set.
|
||||
if (eventName === EVENTS.FORCE_SYNC_PREPARE &&
|
||||
room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
room.controllers && room.controllers.has(mapping.peerId)) {
|
||||
room.forceSyncInitiator = mapping.peerId;
|
||||
}
|
||||
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE &&
|
||||
room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator;
|
||||
if (!isOwnForceSyncExecute &&
|
||||
room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
!(room.controllers && room.controllers.has(mapping.peerId)) &&
|
||||
HOST_ONLY_GATED_EVENTS.has(eventName)) {
|
||||
log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`);
|
||||
return;
|
||||
}
|
||||
// Clear initiator tracking once the EXECUTE has been relayed.
|
||||
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && room.forceSyncInitiator) {
|
||||
room.forceSyncInitiator = null;
|
||||
}
|
||||
|
||||
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
|
||||
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
|
||||
@@ -571,12 +589,13 @@ io.on('connection', (socket) => {
|
||||
room.peerData.set(socket.id, {
|
||||
...existing,
|
||||
username: data.username !== undefined ? (clamp(data.username, 30) ?? existing.username) : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle,
|
||||
tabTitle: data.tabTitle === null ? null : (data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle),
|
||||
mediaTitle: data.mediaTitle === null ? null : (data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle),
|
||||
playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState,
|
||||
currentTime: data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime,
|
||||
currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime),
|
||||
volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume,
|
||||
muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted,
|
||||
desynced: data.desynced !== undefined ? (validBool(data.desynced) === true) : (existing.desynced || false),
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
|
||||
@@ -584,14 +603,15 @@ io.on('connection', (socket) => {
|
||||
const relayPayload = {
|
||||
senderId: mapping.peerId,
|
||||
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
|
||||
currentTime: clampNum(data.currentTime, 0, 86400),
|
||||
currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, 86400),
|
||||
targetTime: clampNum(data.targetTime, 0, 86400),
|
||||
playbackState: validState(data.playbackState),
|
||||
username: clamp(data.username, 30),
|
||||
tabTitle: clamp(data.tabTitle, 100),
|
||||
mediaTitle: clamp(data.mediaTitle, 100),
|
||||
tabTitle: data.tabTitle === null ? null : clamp(data.tabTitle, 100),
|
||||
mediaTitle: data.mediaTitle === null ? null : clamp(data.mediaTitle, 100),
|
||||
volume: clampNum(data.volume, 0, 1),
|
||||
muted: validBool(data.muted),
|
||||
desynced: validBool(data.desynced),
|
||||
peerId: mapping.peerId,
|
||||
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
|
||||
expectedTitle: clamp(data.expectedTitle, 100),
|
||||
@@ -644,11 +664,111 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
socket.on(EVENTS.LEAVE_ROOM, () => {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
socket.leave(mapping.roomId);
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
|
||||
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) {
|
||||
socket.leave(mapping.roomId);
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', 'removePeerFromRoom failed in leave', err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.SET_CONTROL_MODE, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (SET_CONTROL_MODE): ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const mode = data.controlMode;
|
||||
if (mode !== CONTROL_MODES.EVERYONE && mode !== CONTROL_MODES.HOST_ONLY) return;
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (!mapping) return;
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (!room) return;
|
||||
|
||||
// Only the owner may change the control mode.
|
||||
if (mapping.peerId !== room.hostPeerId) {
|
||||
log('AUTH', `Non-host ${mapping.peerId} tried to set control mode in ${mapping.roomId.substring(0, 3)}***`);
|
||||
// Re-sync the sender to the actual room state so any optimistic UI
|
||||
// (e.g. popup toggle) reverts — covers stale-local-amHost races (H-5).
|
||||
socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
return;
|
||||
}
|
||||
if (room.controlMode === mode) return; // no-op, ignore (UI debounce backstop)
|
||||
|
||||
// M-4: per-room debounce — reject toggles faster than CONTROL_MODE_MIN_INTERVAL_MS.
|
||||
// The host still gets the final state via the next legit change; this just
|
||||
// kills the spam vector.
|
||||
const now = Date.now();
|
||||
if (now - room.lastControlModeChangeAt < CONTROL_MODE_MIN_INTERVAL_MS) {
|
||||
log('ROOM', `Control mode toggle debounced in ${mapping.roomId.substring(0, 3)}***`);
|
||||
// Re-sync the sender so any optimistic UI matches the actual (unchanged) state.
|
||||
socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
return;
|
||||
}
|
||||
|
||||
room.controlMode = mode;
|
||||
room.lastControlModeChangeAt = now;
|
||||
room.lastActivity = now;
|
||||
io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Control mode set to '${mode}' by host in room ${mapping.roomId.substring(0, 3)}***`);
|
||||
});
|
||||
|
||||
socket.on(EVENTS.SET_PEER_ROLE, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (SET_PEER_ROLE): ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const targetPeerId = typeof data.peerId === 'string' ? data.peerId.substring(0, 16) : null;
|
||||
const makeController = data.controller === true;
|
||||
if (!targetPeerId) return;
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (!mapping) return;
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (!room || !room.controllers) return;
|
||||
|
||||
// Only the owner may promote/demote controllers.
|
||||
if (mapping.peerId !== room.hostPeerId) {
|
||||
log('AUTH', `Non-owner ${mapping.peerId} tried to set peer role in ${mapping.roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
return;
|
||||
}
|
||||
// The owner is always a controller and cannot be demoted.
|
||||
if (targetPeerId === room.hostPeerId) return;
|
||||
// Target must be a peer currently in the room.
|
||||
const targetPresent = Array.from(room.peerData.values()).some(d => d.peerId === targetPeerId);
|
||||
if (!targetPresent) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - (room.lastRoleChangeAt || 0) < CONTROL_MODE_MIN_INTERVAL_MS) {
|
||||
log('ROOM', `Role change debounced in ${mapping.roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
return;
|
||||
}
|
||||
|
||||
if (makeController) {
|
||||
if (room.controllers.has(targetPeerId)) return; // no-op
|
||||
room.controllers.add(targetPeerId);
|
||||
} else {
|
||||
if (!room.controllers.has(targetPeerId)) return; // no-op
|
||||
room.controllers.delete(targetPeerId);
|
||||
}
|
||||
room.lastRoleChangeAt = now;
|
||||
room.lastActivity = now;
|
||||
io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Peer ${targetPeerId} ${makeController ? 'promoted to' : 'demoted from'} controller in ${mapping.roomId.substring(0, 3)}***`);
|
||||
});
|
||||
|
||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||
@@ -667,12 +787,18 @@ io.on('connection', (socket) => {
|
||||
|
||||
// Security: Only relay ACK if both peers are in the same room
|
||||
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
||||
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
||||
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
||||
senderId: senderMapping.peerId,
|
||||
actionTimestamp: data.actionTimestamp
|
||||
});
|
||||
} else {
|
||||
} else if (senderMapping && targetMapping) {
|
||||
// Both peers exist but live in different rooms — genuinely suspicious.
|
||||
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
|
||||
} else {
|
||||
// Benign + common: sender or target left/disconnected before the ACK
|
||||
// arrived (a command was in-flight when they went). Not an attack —
|
||||
// log quietly (verbose only) so it doesn't drown out real signals.
|
||||
log('ACKDROP', `Dropped ACK from ${socket.id} to absent peer ${data.targetId}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -721,12 +847,14 @@ 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) {
|
||||
// Socket is already disconnected — no need to call socket.leave().
|
||||
// removePeerFromRoom uses io.to() for notifications, which correctly
|
||||
// excludes this dead socket since it has already left all rooms.
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
|
||||
try {
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
|
||||
} catch (err) {
|
||||
log('ERROR', 'removePeerFromRoom failed in disconnect', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -751,12 +879,14 @@ const roomCleanupInterval = setInterval(() => {
|
||||
}
|
||||
}
|
||||
for (const sid of staleSids) {
|
||||
// Gracefully evict the socket from the Socket.IO room if it is
|
||||
// still technically connected (zombie with no heartbeat).
|
||||
const deadSocket = io.sockets?.sockets?.get(sid);
|
||||
if (deadSocket) deadSocket.leave(roomId);
|
||||
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
|
||||
removePeerFromRoom(sid, roomId, 'reaper');
|
||||
try {
|
||||
removePeerFromRoom(sid, roomId, 'reaper');
|
||||
} catch (err) {
|
||||
log('ERROR', 'removePeerFromRoom failed in reaper', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Prune empty or inactive rooms
|
||||
@@ -798,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);
|
||||
@@ -811,22 +943,18 @@ function gracefulShutdown(signal) {
|
||||
}
|
||||
|
||||
export async function stopServerForTests() {
|
||||
clearInterval(authFailureCleanupInterval);
|
||||
clearInterval(rateLimitCleanupInterval);
|
||||
stopRateLimitCleanup();
|
||||
clearInterval(roomCleanupInterval);
|
||||
rooms.clear();
|
||||
socketToRoom.clear();
|
||||
peerToSocket.clear();
|
||||
roomCreationLocks.clear();
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
peerJoinLocks.clear();
|
||||
clearRateLimitMaps();
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
io.disconnectSockets(true);
|
||||
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());
|
||||
@@ -846,8 +974,19 @@ if (isMainModule) {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
let unhandledRejectionCount = 0;
|
||||
let unhandledRejectionReset = Date.now();
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
log('ERROR', `Unhandled rejection: ${reason}`, reason?.stack || '');
|
||||
const now = Date.now();
|
||||
if (now - unhandledRejectionReset > 60000) {
|
||||
unhandledRejectionCount = 0;
|
||||
unhandledRejectionReset = now;
|
||||
}
|
||||
unhandledRejectionCount++;
|
||||
if (unhandledRejectionCount >= 5) {
|
||||
log('ERROR', `Too many unhandled rejections (${unhandledRejectionCount}/min) — aborting`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+27
-11
@@ -33,8 +33,17 @@ export function isAdminMetricsAuthorized(authHeader, adminToken) {
|
||||
const expectedBuffer = Buffer.from(adminToken);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
// Always run timingSafeEqual to prevent length-based timing leaks.
|
||||
// timingSafeEqual throws on different-length buffers, so when lengths
|
||||
// differ we compare against a zeroed buffer of the provided length
|
||||
// (guaranteed mismatch, constant time).
|
||||
// NOTE: timingSafeEqual must be evaluated eagerly (assigned to const)
|
||||
// before the && short-circuit, otherwise it's skipped on length mismatch
|
||||
// and the timing leak remains.
|
||||
const sameLength = expectedBuffer.length === providedBuffer.length;
|
||||
const compareBuf = sameLength ? expectedBuffer : Buffer.alloc(providedBuffer.length);
|
||||
const equal = crypto.timingSafeEqual(compareBuf, providedBuffer);
|
||||
return sameLength && equal;
|
||||
}
|
||||
|
||||
export function isAdminMetricsTokenStrong(adminToken, minLength = 32) {
|
||||
@@ -61,19 +70,24 @@ export function buildHealthPayload({
|
||||
|
||||
if (!includeMetrics) return payload;
|
||||
|
||||
const roomValues = Array.from(rooms.values());
|
||||
const roomSizes = roomValues.map(room => room.peers?.size || 0);
|
||||
const peers = roomSizes.reduce((sum, size) => sum + size, 0);
|
||||
const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0;
|
||||
const avgPeersPerRoom = roomSizes.length > 0
|
||||
? Math.round((peers / roomSizes.length) * 100) / 100
|
||||
let peers = 0;
|
||||
let maxPeersInRoom = 0;
|
||||
let roomsWithLobby = 0;
|
||||
for (const room of rooms.values()) {
|
||||
const size = room.peers?.size || 0;
|
||||
peers += size;
|
||||
if (size > maxPeersInRoom) maxPeersInRoom = size;
|
||||
if (room.activeLobby) roomsWithLobby++;
|
||||
}
|
||||
const avgPeersPerRoom = rooms.size > 0
|
||||
? Math.round((peers / rooms.size) * 100) / 100
|
||||
: 0;
|
||||
const mem = memoryUsage();
|
||||
|
||||
return {
|
||||
...payload,
|
||||
peers,
|
||||
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
|
||||
roomsWithLobby,
|
||||
avgPeersPerRoom,
|
||||
maxPeersInRoom,
|
||||
rateLimits: {
|
||||
@@ -83,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: {
|
||||
|
||||
Generated
+11
-11
@@ -256,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.8",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
|
||||
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
|
||||
"version": "6.6.9",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
|
||||
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
@@ -270,7 +270,7 @@
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.20.1"
|
||||
"ws": "~8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
@@ -941,13 +941,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz",
|
||||
"integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==",
|
||||
"version": "2.5.8",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.20.1"
|
||||
"ws": "~8.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
@@ -1069,9 +1069,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* KoalaSync Rate Limiter
|
||||
* Connection, event, health, and auth rate limiting for the relay server.
|
||||
*/
|
||||
|
||||
export const ROOM_LIST_COOLDOWN_MS = 10000;
|
||||
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
|
||||
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
|
||||
|
||||
// --- Connection & event budgets (formerly inline magic numbers) ---
|
||||
export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP per window
|
||||
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
|
||||
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const 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}>
|
||||
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,
|
||||
leaveRoom: 0
|
||||
};
|
||||
|
||||
let authCleanupId = null;
|
||||
let rateLimitCleanupId = null;
|
||||
|
||||
export function checkAuthRate(ip, roomId) {
|
||||
const key = `${ip}:${roomId}`;
|
||||
const now = Date.now();
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
|
||||
if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((now - record.lastAttempt) > 2 * 60 * 1000) {
|
||||
record.count = 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
console.warn('SECURITY: failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.');
|
||||
for (const [key] of failedAuthAttempts.entries()) {
|
||||
if (failedAuthAttempts.size <= 190000) {
|
||||
break;
|
||||
}
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = `${ip}:${roomId}`;
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
record.count++;
|
||||
record.lastAttempt = Date.now();
|
||||
failedAuthAttempts.delete(key);
|
||||
failedAuthAttempts.set(key, record);
|
||||
}
|
||||
|
||||
export function checkConnectionRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + CONNECTION_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CONNECTION_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
connectionCounts.set(ip, entry);
|
||||
if (entry.count <= CONNECTION_RATE_LIMIT) return true;
|
||||
rateLimitDenied.connections++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkEventRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + EVENT_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + EVENT_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
eventCounts.set(socketId, entry);
|
||||
if (entry.count <= EVENT_RATE_LIMIT) return true;
|
||||
rateLimitDenied.events++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + HEALTH_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
rateLimitDenied.health++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkAdminMetricsAuthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + ADMIN_METRICS_AUTH_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + ADMIN_METRICS_AUTH_WINDOW_MS; }
|
||||
entry.count++;
|
||||
adminMetricsAuthCounts.set(ip, entry);
|
||||
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
rateLimitDenied.adminMetricsAuth++;
|
||||
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)
|
||||
authCleanupId = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
// Clean up rate-limit maps to prevent memory leaks (every 60 seconds)
|
||||
rateLimitCleanupId = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
if (now > entry.resetTime) connectionCounts.delete(ip);
|
||||
}
|
||||
for (const [socketId, entry] of eventCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
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);
|
||||
}
|
||||
for (const [ip, entry] of adminMetricsAuthCounts.entries()) {
|
||||
if (now > entry.resetTime) adminMetricsAuthCounts.delete(ip);
|
||||
}
|
||||
for (const [socketId] of roomListCooldowns.entries()) {
|
||||
if (!io.sockets.sockets.has(socketId)) roomListCooldowns.delete(socketId);
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
export function stopRateLimitCleanup() {
|
||||
if (authCleanupId) { clearInterval(authCleanupId); authCleanupId = null; }
|
||||
if (rateLimitCleanupId) { clearInterval(rateLimitCleanupId); rateLimitCleanupId = null; }
|
||||
}
|
||||
|
||||
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
-1
@@ -4,7 +4,7 @@ This directory contains constants and protocol definitions used by both the exte
|
||||
|
||||
## Syncing with the Extension
|
||||
> [!IMPORTANT]
|
||||
> Every time this directory is modified, you must run `node scripts/build-extension.js` to keep the extension's copy up to date.
|
||||
> 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.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* blacklist.js
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.cjs
|
||||
* to propagate changes to the extension and relay server.
|
||||
*
|
||||
* Domains to be filtered out from the tab selection dropdown to reduce "noise".
|
||||
|
||||
+25
-3
@@ -2,18 +2,17 @@
|
||||
* KoalaSync Shared Constants & Protocol Definitions
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.cjs
|
||||
* to propagate changes to the extension and relay server.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.9.0";
|
||||
export const APP_VERSION = "2.5.2";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
export const OFFICIAL_SERVER_TOKEN = '62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50';
|
||||
export const SUPPORT_URL = 'https://support.koalastuff.net';
|
||||
export const KOFI_URL = 'https://ko-fi.com/koaladev';
|
||||
export const GITHUB_URL = 'https://github.com/Shik3i/KoalaSync';
|
||||
|
||||
export function isFirefox() {
|
||||
@@ -34,6 +33,11 @@ export const EVENTS = {
|
||||
ROOM_DATA: "room_data", // Server -> Client: current room state
|
||||
ERROR: "error",
|
||||
|
||||
// Host Control Mode
|
||||
SET_CONTROL_MODE: "set_control_mode", // Client -> Server: host sets room control mode ('everyone' | 'host-only')
|
||||
CONTROL_MODE: "control_mode", // Server -> Client: control mode/role changed { controlMode, hostPeerId, controllers }
|
||||
SET_PEER_ROLE: "set_peer_role", // Client -> Server: owner promotes/demotes a peer { peerId, controller }
|
||||
|
||||
// Media Control
|
||||
PLAY: "play",
|
||||
PAUSE: "pause",
|
||||
@@ -58,6 +62,24 @@ export const EVENTS = {
|
||||
PONG: "pong" // server responds with same { t } for client RTT calculation
|
||||
};
|
||||
|
||||
// Room control modes (Host Control Mode feature).
|
||||
// NOTE: content.js does not import this module — it uses the string literals
|
||||
// 'everyone' / 'host-only' directly. Keep these values in sync there.
|
||||
export const CONTROL_MODES = {
|
||||
EVERYONE: 'everyone', // default: anyone can play/pause/seek for the room
|
||||
HOST_ONLY: 'host-only' // only the host drives the room
|
||||
};
|
||||
|
||||
// Server feature capabilities, advertised to clients in ROOM_DATA. Lets a client
|
||||
// detect what the relay actually supports instead of inferring it from the
|
||||
// presence of a data field — so new server features degrade cleanly on older
|
||||
// relays (unknown/absent list → feature treated as unavailable) and old clients
|
||||
// simply ignore the field. Add a flag here as each server-gated feature lands.
|
||||
export const CAPABILITIES = {
|
||||
HOST_CONTROL: 'host-control',
|
||||
CO_HOST: 'co-host' // owner promotes guests to additional controllers
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
|
||||
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* KoalaSync Shared Name Generation & Emoji Mapping
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.cjs
|
||||
* to propagate changes to the extension.
|
||||
*
|
||||
* The emoji map covers every animal/creature that has a Unicode emoji.
|
||||
|
||||
@@ -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/**'
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
+5
-5
@@ -5,7 +5,7 @@ This directory contains the KoalaSync website. It serves a dual purpose: it is b
|
||||
## Core Roles
|
||||
|
||||
### 1. Marketing & Onboarding
|
||||
Provides a premium, multi-language (EN/DE/FR/ES/PT-BR/RU) overview of features, setup instructions, and direct links to the extension stores.
|
||||
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:
|
||||
@@ -16,8 +16,8 @@ The website handles incoming invitation links. When a user clicks a link like `s
|
||||
## Architecture
|
||||
|
||||
The website is 100% **Static HTML, CSS, and JS**.
|
||||
- **Static i18n Compiler**: The site uses a lightweight, zero-dependency Node.js compiler (`build.js`) to parse dictionary files inside `/locales/` against a single source-of-truth template (`template.html`), outputting the fully deployable static folder to `/www/`.
|
||||
- **Build-time Minification**: `build.js` automatically minifies CSS and JS during compilation using a built-in state-machine tokenizer (no npm dependencies). Source files are written unminified (`style.css`, `app.js`, `lang-init.js`) — always edit source files, never the generated `.min.*` files in `www/`.
|
||||
- **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.
|
||||
@@ -62,7 +62,7 @@ sync.koalastuff.net {
|
||||
|
||||
1. Run the compilation script from the repository root to generate the `/website/www` folder:
|
||||
```bash
|
||||
node website/build.js
|
||||
node website/build.cjs
|
||||
```
|
||||
2. Serve the compiled `/www` directory using any local development server:
|
||||
```bash
|
||||
@@ -71,4 +71,4 @@ sync.koalastuff.net {
|
||||
3. To test the invitation flow locally, navigate to `http://localhost:5000/join.html#join:test-room:test-pass`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Never edit files inside `website/www/` directly.** This directory is fully auto-generated by `build.js`. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, locale files in `locales/`) and re-run `node website/build.js` to apply changes. CSS and JS are output as `style.min.css`, `app.min.js`, and `lang-init.min.js` — the `.min.*` naming makes it visually obvious these are build artifacts. Editing minified files in `www/` will result in lost changes on the next build.
|
||||
> **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.
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# KoalaSync Translation & Localization Guide
|
||||
|
||||
Welcome to the **KoalaSync** translation and internationalization framework! This document provides clear, professional instructions for developers and contributors looking to maintain, audit, or add new languages to the official KoalaSync website.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architecture Overview
|
||||
|
||||
The KoalaSync website utilizes a custom, high-performance, zero-dependency static site generator built in Node.js. Instead of using complex client-side translation runtimes or bulky frameworks, localized pages are compiled ahead-of-time (AOT) to maintain lightning-fast page speeds and strict data sovereignty.
|
||||
|
||||
* **Template Source:** [`website/template.html`](file:///Users/koala/Documents/KoalaPlay/website/template.html) (Single Source of Truth)
|
||||
* **Locales Source:** `/website/locales/[lang].json` (Structured JSON translation dictionaries)
|
||||
* **Build Pipeline:** [`website/build.js`](file:///Users/koala/Documents/KoalaPlay/website/build.js) (Pure Node.js script that compiles pages into `/website/www/`)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Supported Languages Dashboard
|
||||
|
||||
We divide supported languages into two tiers: **Core Languages** (fully hand-crafted and audited by native speakers) and **Extended Languages** (auto-generated using translation models to expand initial coverage).
|
||||
|
||||
> [!TIP]
|
||||
> **Help Us Improve!**
|
||||
> We welcome community contributions to audit "Auto-Generated" translations and elevate them to "Verified" status.
|
||||
|
||||
| Language Code | Language Name | Verification Status | Rationale / Context |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `en` | 🇬🇧 **English** | `100% Manually Verified` | Primary developer language and system default |
|
||||
| `de` | 🇩🇪 **German** | `100% Manually Verified` | Core market and compliance baseline |
|
||||
| `fr` | 🇫🇷 **French** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `es` | 🇪🇸 **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt-BR` | 🇧🇷 **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ru` | 🇷🇺 **Russian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `it` | 🇮🇹 **Italian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pl` | 🇵🇱 **Polish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `tr` | 🇹🇷 **Turkish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `nl` | 🇳🇱 **Dutch** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ja` | 🇯🇵 **Japanese** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ko` | 🇰🇷 **Korean** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt` | 🇵🇹 **European Portuguese** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
|
||||
> [!WARNING]
|
||||
> **Autogeneration Quality Rule**
|
||||
> Any newly contributed languages must be committed as `"Auto-Generated"` until fully reviewed and signed off by a native speaker in a pull request.
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ Strict Legal Exclusion Rule
|
||||
|
||||
Our legal pages have strict constraints to protect user privacy and avoid regulatory liabilities.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **DO NOT TRANSLATE LEGAL DOCUMENTS**
|
||||
> The legal notice ([impressum.html](file:///Users/koala/Documents/KoalaPlay/website/impressum.html)) and privacy policy ([datenschutz.html](file:///Users/koala/Documents/KoalaPlay/website/datenschutz.html)) **MUST remain exclusively in English and German**.
|
||||
>
|
||||
> * **Rationale:** Legal compliance under the European Union General Data Protection Regulation (GDPR) and the German Digital Services Act (DDG). Offering automated translations of legally binding notices introduces compliance risks due to potential mistranslations of liability limits.
|
||||
> * **Technical Fallback:** The dynamic initializer script (`lang-init.js`) is configured to automatically fallback to **English** for legal pages if a user visits them with a French, Spanish, or other unsupported language preference, keeping their dynamic dropdown choice intact for homepage links.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Step-by-Step: Adding a New Language
|
||||
|
||||
Adding a new language (e.g., Italian - `it`) is straightforward. Follow these four structured steps:
|
||||
|
||||
### Step 1: Create the Translation Dictionary
|
||||
Create a new JSON file inside the locales directory named `[lang].json` (e.g., `website/locales/it.json`).
|
||||
1. Copy the structure of [`website/locales/en.json`](file:///Users/koala/Documents/KoalaPlay/website/locales/en.json) to use as your baseline.
|
||||
2. Translate all string values while keeping the JSON keys identical.
|
||||
3. Configure the language metadata keys at the top of the file:
|
||||
```json
|
||||
{
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"CANONICAL_PATH": "it/",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Register in the Compiler
|
||||
Open the static site generator script [`website/build.js`](file:///Users/koala/Documents/KoalaPlay/website/build.js) and append your new language code to the active `languages` array:
|
||||
```javascript
|
||||
// Add 'it' to the array
|
||||
const languages = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it'];
|
||||
```
|
||||
|
||||
### Step 3: Run the Build Script
|
||||
Execute the compiler from the root of the repository:
|
||||
```bash
|
||||
node website/build.js
|
||||
```
|
||||
The compiler will automatically:
|
||||
1. Load the new JSON translation file.
|
||||
2. Create the target subdirectory `/website/www/it/`.
|
||||
3. Generate the compiled `/website/www/it/index.html` landing page, injecting correct relative assets and canonical metadata.
|
||||
|
||||
### Step 4: Update the Dashboard
|
||||
Open this `TRANSLATION.md` file and add your language to the **Supported Languages Dashboard** table, marking it as `Auto-Generated` (unless manually verified).
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future Roadmap: Dynamic Utility Pages
|
||||
|
||||
For pages that require fully dynamic, client-side interactions (like the room invitation bridge [`join.html`](file:///Users/koala/Documents/KoalaPlay/website/join.html)), we need to scale to unlimited languages without bloating the HTML size or polluting the URL.
|
||||
|
||||
### Clean Client-Side i18n Architecture
|
||||
|
||||
To maintain zero URL pollution (e.g. keeping invitation links clean as `/join.html#join:roomID:password`), we propose an **asynchronous JSON dictionary injection architecture**:
|
||||
|
||||
#### 1. Page Lifecycle Flow
|
||||
1. **User Landing:** The guest enters `/join.html` with a shared hash.
|
||||
2. **Language Resolution:** `lang-init.js` immediately reads their saved preference (`localStorage` or `navigator.language`) and applies the active class (e.g. `html.lang = "es"`).
|
||||
3. **Async Fetching:** A client-side loader script (`i18n-client.js`) runs asynchronously, downloading the correct dictionary (`fetch("/locales/es.json")`).
|
||||
4. **DOM Translation:** The script scans the page for elements carrying a `data-i18n` attribute and safely updates their text content at runtime, avoiding dual-text nodes and stylesheet recalculations.
|
||||
|
||||
#### 2. Declarative HTML Markup
|
||||
Elements are defined with custom data attributes specifying translation keys. English text is placed as the static HTML fallback:
|
||||
```html
|
||||
<h1 data-i18n="JOIN_TITLE">Ready to sync?</h1>
|
||||
<p id="join-desc" data-i18n="JOIN_SUBTITLE">You've been invited to join a session.</p>
|
||||
```
|
||||
|
||||
#### 3. Zero-Dependency Engine (`i18n-client.js`)
|
||||
```javascript
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 1. Recover the language determined during early initialization
|
||||
const activeLang = document.documentElement.lang || 'en';
|
||||
if (activeLang === 'en') return; // Default markup is already in English
|
||||
|
||||
// 2. Fetch the corresponding locale JSON asynchronously
|
||||
try {
|
||||
const response = await fetch(`locales/${activeLang}.json`);
|
||||
if (!response.ok) throw new Error('Locale file unavailable');
|
||||
const dictionary = await response.json();
|
||||
|
||||
// 3. Scan and translate data-i18n attributes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (dictionary[key]) {
|
||||
if (el.tagName === 'IMG') {
|
||||
el.alt = dictionary[key];
|
||||
} else {
|
||||
el.textContent = dictionary[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Dynamic i18n loading failed. Defaulting to English:', err);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Core Benefits
|
||||
* **Zero URL Pollution:** Keeps invitation hashes private and avoids messy query parameters (`?lang=de`), protecting user privacy.
|
||||
* **Optimal Performance:** Eliminates duplicate hidden text blocks, cutting page weight in half and ensuring smooth rendering.
|
||||
* **Infinite Scale:** Adding new languages to dynamic pages requires zero edits to HTML markup; the engine simply fetches new JSON dictionaries on-demand.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Extension Internationalization (i18n)
|
||||
|
||||
In **v2.0**, we extended full internationalization support to the **Browser Extension itself**. The architecture mirrors our web-based dynamic localization model to maintain complete parity.
|
||||
|
||||
* **Locales Directory:** [`extension/locales/`](file:///Users/koala/Documents/KoalaPlay/extension/locales/)
|
||||
* **Active Dictionaries:**
|
||||
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (🇬🇧 Baseline English)
|
||||
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (🇩🇪 German)
|
||||
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (🇫🇷 French)
|
||||
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (🇪🇸 Spanish)
|
||||
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (🇧🇷 Portuguese (Brasil))
|
||||
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (🇷🇺 Russian)
|
||||
* [`it.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/it.json) (🇮🇹 Italian)
|
||||
* [`pl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pl.json) (🇵🇱 Polish)
|
||||
* [`tr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/tr.json) (🇹🇷 Turkish)
|
||||
* [`nl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/nl.json) (🇳🇱 Dutch)
|
||||
* [`ja.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ja.json) (🇯🇵 Japanese)
|
||||
* [`ko.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ko.json) (🇰🇷 Korean)
|
||||
* [`pt.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt.json) (🇵🇹 European Portuguese)
|
||||
* **Translation Engine:** [`extension/i18n.js`](file:///Users/koala/Documents/KoalaPlay/extension/i18n.js)
|
||||
* **Validation Script:** [`scripts/test-locales.js`](file:///Users/koala/Documents/KoalaPlay/scripts/test-locales.js)
|
||||
|
||||
### ⚙️ How it Works inside the Extension
|
||||
|
||||
1. **System Locale Auto-Detection**: On first run, the extension detects the browser system language using `navigator.language` or `chrome.i18n.getUILanguage()`.
|
||||
2. **On-the-Fly Redraws**: When the user selects a different language in the settings tab (`#langSelector`), the selection is stored in `chrome.storage.sync` and the translation engine immediately triggers `translateDOM()`. The interface, empty state cards, tooltips, dynamic onboarding tutorial guides, and status badges re-render instantly without reloading the popup.
|
||||
3. **Localized System Notifications**: On play, pause, or seek commands, `background.js` retrieves the user's active locale preference from storage, loads the correct dictionary, and pushes native OS notifications fully translated.
|
||||
|
||||
### 🧪 Auditing & Sync Checks
|
||||
|
||||
To ensure that no language dictionary falls out of sync (causing missing labels or blank interfaces), developers must run the locale auditor tool before packaging releases:
|
||||
```bash
|
||||
node scripts/test-locales.js
|
||||
```
|
||||
This script asserts that all JSON dictionary files under `extension/locales/` share exactly the same set of keys as the English baseline (`en.json`).
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<!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_INDEX_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">
|
||||
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/alternatives">
|
||||
<link rel="alternate" hreflang="fr" href="https://sync.koalastuff.net/fr/alternatives">
|
||||
<link rel="alternate" hreflang="es" href="https://sync.koalastuff.net/es/alternatives">
|
||||
<link rel="alternate" hreflang="pt-br" href="https://sync.koalastuff.net/pt-BR/alternatives">
|
||||
<link rel="alternate" hreflang="ru" href="https://sync.koalastuff.net/ru/alternatives">
|
||||
<link rel="alternate" hreflang="it" href="https://sync.koalastuff.net/it/alternatives">
|
||||
<link rel="alternate" hreflang="pl" href="https://sync.koalastuff.net/pl/alternatives">
|
||||
<link rel="alternate" hreflang="tr" href="https://sync.koalastuff.net/tr/alternatives">
|
||||
<link rel="alternate" hreflang="nl" href="https://sync.koalastuff.net/nl/alternatives">
|
||||
<link rel="alternate" hreflang="ja" href="https://sync.koalastuff.net/ja/alternatives">
|
||||
<link rel="alternate" hreflang="ko" href="https://sync.koalastuff.net/ko/alternatives">
|
||||
<link rel="alternate" hreflang="zh" href="https://sync.koalastuff.net/zh/alternatives">
|
||||
<link rel="alternate" hreflang="uk" href="https://sync.koalastuff.net/uk/alternatives">
|
||||
<link rel="alternate" hreflang="pt" href="https://sync.koalastuff.net/pt/alternatives">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/alternatives">
|
||||
|
||||
<meta name="description" content="{{ALT_INDEX_META_DESC}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives">
|
||||
<meta property="og:title" content="{{ALT_INDEX_TITLE}}">
|
||||
<meta property="og:description" content="{{ALT_INDEX_META_DESC}}">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/RemoteProf.webp">
|
||||
<meta property="og:locale" content="{{OG_LOCALE}}">
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ALT_INDEX_TITLE}}">
|
||||
<meta name="twitter:description" content="{{ALT_INDEX_META_DESC}}">
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/RemoteProf.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"
|
||||
}
|
||||
]
|
||||
}
|
||||
</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="#0f172a">
|
||||
<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>
|
||||
|
||||
<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: 2.5rem 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<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>
|
||||
<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>
|
||||
</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;">
|
||||
<!-- 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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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,217 @@
|
||||
<!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_SCREEN_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/screen-sharing">
|
||||
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="fr" href="https://sync.koalastuff.net/fr/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="es" href="https://sync.koalastuff.net/es/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="pt-br" href="https://sync.koalastuff.net/pt-BR/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="ru" href="https://sync.koalastuff.net/ru/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="it" href="https://sync.koalastuff.net/it/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="pl" href="https://sync.koalastuff.net/pl/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="tr" href="https://sync.koalastuff.net/tr/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="nl" href="https://sync.koalastuff.net/nl/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="ja" href="https://sync.koalastuff.net/ja/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="ko" href="https://sync.koalastuff.net/ko/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="zh" href="https://sync.koalastuff.net/zh/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="uk" href="https://sync.koalastuff.net/uk/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="pt" href="https://sync.koalastuff.net/pt/alternatives/screen-sharing">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/alternatives/screen-sharing">
|
||||
|
||||
<meta name="description" content="{{ALT_SCREEN_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/screen-sharing">
|
||||
<meta property="og:title" content="{{ALT_SCREEN_TITLE}}">
|
||||
<meta property="og:description" content="{{ALT_SCREEN_META_DESC}}">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/PopcornFriends.webp">
|
||||
<meta property="og:locale" content="{{OG_LOCALE}}">
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ALT_SCREEN_TITLE}}">
|
||||
<meta name="twitter:description" content="{{ALT_SCREEN_META_DESC}}">
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/PopcornFriends.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 Screen Sharing",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/screen-sharing"
|
||||
}
|
||||
]
|
||||
}
|
||||
</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="#0f172a">
|
||||
<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>
|
||||
|
||||
<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/PopcornFriends.webp" alt="Koalas watching movie with popcorn" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
|
||||
<h1>{{ALT_SCREEN_TITLE}}</h1>
|
||||
|
||||
<!-- Intro -->
|
||||
<section>
|
||||
<p><em>{{ALT_SCREEN_INTRO}}</em></p>
|
||||
</section>
|
||||
|
||||
<!-- When screen sharing is enough -->
|
||||
<section>
|
||||
<h2>{{ALT_SCREEN_WHEN_ENOUGH_TITLE}}</h2>
|
||||
<p>{{ALT_SCREEN_WHEN_ENOUGH_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Where screen sharing gets annoying -->
|
||||
<section>
|
||||
<h2>{{ALT_SCREEN_WHEN_ANNOYING_TITLE}}</h2>
|
||||
<p>{{ALT_SCREEN_WHEN_ANNOYING_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Why KoalaSync can feel cleaner -->
|
||||
<section>
|
||||
<h2>{{ALT_SCREEN_WHY_CLEANER_TITLE}}</h2>
|
||||
<p>{{ALT_SCREEN_WHY_CLEANER_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Honest limitations -->
|
||||
<section>
|
||||
<h2>{{ALT_SCREEN_LIMITS_TITLE}}</h2>
|
||||
<p>{{ALT_SCREEN_LIMITS_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Summary -->
|
||||
<section>
|
||||
<h2>{{ALT_SCREEN_SUMMARY_TITLE}}</h2>
|
||||
<p>{{ALT_SCREEN_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;">
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/teleparty" style="color: var(--accent); text-decoration: none;">{{ALT_TELEPARTY_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,217 @@
|
||||
<!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_TELEPARTY_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/teleparty">
|
||||
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="fr" href="https://sync.koalastuff.net/fr/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="es" href="https://sync.koalastuff.net/es/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="pt-br" href="https://sync.koalastuff.net/pt-BR/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="ru" href="https://sync.koalastuff.net/ru/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="it" href="https://sync.koalastuff.net/it/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="pl" href="https://sync.koalastuff.net/pl/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="tr" href="https://sync.koalastuff.net/tr/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="nl" href="https://sync.koalastuff.net/nl/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="ja" href="https://sync.koalastuff.net/ja/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="ko" href="https://sync.koalastuff.net/ko/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="zh" href="https://sync.koalastuff.net/zh/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="uk" href="https://sync.koalastuff.net/uk/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="pt" href="https://sync.koalastuff.net/pt/alternatives/teleparty">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/alternatives/teleparty">
|
||||
|
||||
<meta name="description" content="{{ALT_TELEPARTY_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/teleparty">
|
||||
<meta property="og:title" content="{{ALT_TELEPARTY_TITLE}}">
|
||||
<meta property="og:description" content="{{ALT_TELEPARTY_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_TELEPARTY_TITLE}}">
|
||||
<meta name="twitter:description" content="{{ALT_TELEPARTY_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 Teleparty",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/teleparty"
|
||||
}
|
||||
]
|
||||
}
|
||||
</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="#0f172a">
|
||||
<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>
|
||||
|
||||
<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_TELEPARTY_TITLE}}</h1>
|
||||
|
||||
<!-- Intro -->
|
||||
<section>
|
||||
<p><em>{{ALT_TELEPARTY_INTRO}}</em></p>
|
||||
</section>
|
||||
|
||||
<!-- When Teleparty may still make sense -->
|
||||
<section>
|
||||
<h2>{{ALT_TELEPARTY_WHEN_TELE_TITLE}}</h2>
|
||||
<p>{{ALT_TELEPARTY_WHEN_TELE_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- When KoalaSync may fit better -->
|
||||
<section>
|
||||
<h2>{{ALT_TELEPARTY_WHEN_KOALA_TITLE}}</h2>
|
||||
<p>{{ALT_TELEPARTY_WHEN_KOALA_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- The main difference -->
|
||||
<section>
|
||||
<h2>{{ALT_TELEPARTY_MAIN_DIFF_TITLE}}</h2>
|
||||
<p>{{ALT_TELEPARTY_MAIN_DIFF_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Honest limitations -->
|
||||
<section>
|
||||
<h2>{{ALT_TELEPARTY_LIMITS_TITLE}}</h2>
|
||||
<p>{{ALT_TELEPARTY_LIMITS_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Summary -->
|
||||
<section>
|
||||
<h2>{{ALT_TELEPARTY_SUMMARY_TITLE}}</h2>
|
||||
<p>{{ALT_TELEPARTY_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;">
|
||||
<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
-35
@@ -19,20 +19,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Scroll Reveal Logic (IntersectionObserver for performance)
|
||||
const revealElements = document.querySelectorAll('[data-reveal]');
|
||||
|
||||
const revealObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('revealed');
|
||||
revealObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '0px 0px -150px 0px',
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
revealElements.forEach(el => revealObserver.observe(el));
|
||||
if ('IntersectionObserver' in window) {
|
||||
const revealObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('revealed');
|
||||
revealObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '0px 0px -150px 0px',
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
revealElements.forEach(el => revealObserver.observe(el));
|
||||
} else {
|
||||
// Fallback: without IntersectionObserver support, reveal everything
|
||||
// immediately so no content can ever stay hidden.
|
||||
revealElements.forEach(el => el.classList.add('revealed'));
|
||||
}
|
||||
|
||||
// Auto-update URL hash as user scrolls through sections
|
||||
// (preserves position across language switches)
|
||||
if ('IntersectionObserver' in window) {
|
||||
const sectionObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
history.replaceState(null, null, '#' + entry.target.id);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
document.querySelectorAll('section[id], header[id]').forEach(el => sectionObserver.observe(el));
|
||||
}
|
||||
|
||||
// Navbar scroll effect
|
||||
const nav = document.querySelector('nav');
|
||||
@@ -46,20 +65,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Smooth scroll for anchors
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Invite Detection & Bridge
|
||||
const checkInvite = () => {
|
||||
const isJoinPage = window.location.pathname.includes('join');
|
||||
@@ -326,7 +331,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const updateDynamicVersion = async () => {
|
||||
try {
|
||||
const versionPath = document.documentElement.lang === 'en' ? 'version.json' : '../version.json';
|
||||
const versionPath = '/version.json';
|
||||
const response = await fetch(versionPath);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
@@ -475,14 +480,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const activeLang = safeGetLocalStorage('koala_lang') || (navigator.language.startsWith('de') ? 'de' : 'en');
|
||||
const path = window.location.pathname;
|
||||
const pathSegments = path.split('/');
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'].includes(seg));
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'].includes(seg));
|
||||
|
||||
// Only need to do this dynamic rewrite if we are NOT already inside a localized subdirectory
|
||||
if (!isSubdir) {
|
||||
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="pt-BR/"], a[href="ru/"], a[href="it/"], a[href="pl/"], a[href="tr/"], a[href="nl/"], a[href="ja/"], a[href="ko/"], a[href="pt/"]');
|
||||
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="it/"], a[href="nl/"], a[href="pl/"], a[href="pt/"], a[href="pt-BR/"], a[href="tr/"], a[href="ru/"], a[href="ja/"], a[href="ko/"], a[href="zh/"], a[href="uk/"]');
|
||||
homeLinks.forEach(link => {
|
||||
link.href = (activeLang === 'en') ? './' : `${activeLang}/`;
|
||||
});
|
||||
|
||||
const altLinks = document.querySelectorAll('a[href*="alternatives"]');
|
||||
altLinks.forEach(link => {
|
||||
const attr = link.getAttribute('href');
|
||||
if (attr === 'alternatives' || attr === 'alternatives/teleparty' || attr.endsWith('/alternatives')) {
|
||||
link.href = (activeLang === 'en') ? 'alternatives' : `${activeLang}/alternatives`;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -508,7 +521,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
target = hasHtml ? 'imprint.html' : 'imprint';
|
||||
if (path.includes('/de/')) target = hasHtml ? '../imprint.html' : '../imprint';
|
||||
}
|
||||
window.location.href = target;
|
||||
window.location.href = target + window.location.hash;
|
||||
return;
|
||||
} else if (isLegalPrivacy) {
|
||||
let target;
|
||||
@@ -520,7 +533,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
target = hasHtml ? 'privacy.html' : 'privacy';
|
||||
if (path.includes('/de/')) target = hasHtml ? '../privacy.html' : '../privacy';
|
||||
}
|
||||
window.location.href = target;
|
||||
window.location.href = target + window.location.hash;
|
||||
return;
|
||||
}
|
||||
|
||||
const pathSegments = path.split('/');
|
||||
const isAlternative = pathSegments.includes('alternatives');
|
||||
if (isAlternative) {
|
||||
let target;
|
||||
const firstSeg = pathSegments[1];
|
||||
const isLangSubdir = ['de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'].includes(firstSeg);
|
||||
|
||||
if (newLang === 'en') {
|
||||
if (isLangSubdir) {
|
||||
pathSegments.splice(1, 1);
|
||||
}
|
||||
} else {
|
||||
if (isLangSubdir) {
|
||||
pathSegments[1] = newLang;
|
||||
} else {
|
||||
pathSegments.splice(1, 0, newLang);
|
||||
}
|
||||
}
|
||||
target = pathSegments.join('/');
|
||||
window.location.href = target + window.location.hash;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -530,7 +566,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (isIndex) {
|
||||
// Static navigation: Route to correct subdirectory
|
||||
const pathSegments = path.split('/');
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'].includes(seg));
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'].includes(seg));
|
||||
|
||||
let targetPath;
|
||||
if (newLang === 'en') {
|
||||
@@ -549,11 +585,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = targetPath;
|
||||
window.location.href = targetPath + window.location.hash;
|
||||
} else {
|
||||
// Dynamic page: Toggle classes and update elements dynamically without navigating away
|
||||
const html = document.documentElement;
|
||||
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-pt-br', 'lang-ru', 'lang-it', 'lang-pl', 'lang-tr', 'lang-nl', 'lang-ja', 'lang-ko', 'lang-pt');
|
||||
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-it', 'lang-nl', 'lang-pl', 'lang-pt', 'lang-pt-br', 'lang-tr', 'lang-ru', 'lang-ja', 'lang-ko', 'lang-zh', 'lang-uk');
|
||||
|
||||
// Fallback dynamic pages to 'en' if 'de' is not chosen (since fr/es markup is not present)
|
||||
const activeDisplayLang = (newLang === 'de') ? 'de' : 'en';
|
||||
|
||||
@@ -162,7 +162,7 @@ async function compile() {
|
||||
}
|
||||
const templateContent = fs.readFileSync(templatePath, 'utf8');
|
||||
const localesDir = path.join(websiteDir, 'locales');
|
||||
const languages = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'];
|
||||
const languages = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'];
|
||||
|
||||
// Read version for build-time injection (SEO: crawlers see real version)
|
||||
const versionJson = JSON.parse(fs.readFileSync(path.join(websiteDir, 'version.json'), 'utf8'));
|
||||
@@ -174,6 +174,8 @@ async function compile() {
|
||||
let compiled = templateContent;
|
||||
compiled = compiled.replace(/\{\{ASSET_PATH\}\}/g, assetPath);
|
||||
compiled = compiled.replace(/\{\{VERSION\}\}/g, buildVersion);
|
||||
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' : '');
|
||||
});
|
||||
@@ -183,6 +185,53 @@ async function compile() {
|
||||
return compiled;
|
||||
}
|
||||
|
||||
// Read alternative page templates
|
||||
const telepartyTemplatePath = path.join(websiteDir, 'alternatives/teleparty.html');
|
||||
const screenSharingTemplatePath = path.join(websiteDir, 'alternatives/screen-sharing.html');
|
||||
const overviewTemplatePath = path.join(websiteDir, 'alternatives/index.html');
|
||||
const hasTelepartyTemplate = fs.existsSync(telepartyTemplatePath);
|
||||
const hasScreenSharingTemplate = fs.existsSync(screenSharingTemplatePath);
|
||||
const hasOverviewTemplate = fs.existsSync(overviewTemplatePath);
|
||||
const telepartyTemplate = hasTelepartyTemplate ? fs.readFileSync(telepartyTemplatePath, 'utf8') : '';
|
||||
const screenSharingTemplate = hasScreenSharingTemplate ? fs.readFileSync(screenSharingTemplatePath, 'utf8') : '';
|
||||
const overviewTemplate = hasOverviewTemplate ? fs.readFileSync(overviewTemplatePath, 'utf8') : '';
|
||||
|
||||
// Preload English locale for fallback
|
||||
const englishLocalePath = path.join(localesDir, 'en.json');
|
||||
const englishLocale = JSON.parse(fs.readFileSync(englishLocalePath, 'utf8'));
|
||||
|
||||
function compileAlternativePage(templateContent, locale, lang, assetPath, langPrefix) {
|
||||
let compiled = templateContent;
|
||||
compiled = compiled.replace(/\{\{LANG_CODE\}\}/g, lang);
|
||||
compiled = compiled.replace(/\{\{ASSET_PATH\}\}/g, assetPath);
|
||||
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',
|
||||
'pt-BR': 'pt_BR', ru: 'ru_RU', it: 'it_IT', pl: 'pl_PL',
|
||||
tr: 'tr_TR', nl: 'nl_NL', ja: 'ja_JP', ko: 'ko_KR',
|
||||
zh: 'zh_CN', uk: 'uk_UA', pt: 'pt_PT'
|
||||
};
|
||||
compiled = compiled.replace(/\{\{OG_LOCALE\}\}/g, ogLocales[lang] || 'en_US');
|
||||
|
||||
// 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);
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
for (const lang of languages) {
|
||||
const localePath = path.join(localesDir, `${lang}.json`);
|
||||
if (!fs.existsSync(localePath)) { console.warn(`Warning: Locale ${lang} not found.`); continue; }
|
||||
@@ -193,6 +242,26 @@ async function compile() {
|
||||
const html = compilePage(locale, '', lang);
|
||||
fs.writeFileSync(path.join(wwwDir, 'index.html'), html);
|
||||
englishHtml[''] = html;
|
||||
|
||||
// Compile alternatives for English (assets resolved using '../' prefix)
|
||||
if (hasTelepartyTemplate) {
|
||||
const altDir = path.join(wwwDir, 'alternatives');
|
||||
fs.mkdirSync(altDir, { recursive: true });
|
||||
const tpCompiled = compileAlternativePage(telepartyTemplate, locale, lang, '../', '');
|
||||
fs.writeFileSync(path.join(altDir, 'teleparty.html'), tpCompiled);
|
||||
}
|
||||
if (hasScreenSharingTemplate) {
|
||||
const altDir = path.join(wwwDir, 'alternatives');
|
||||
fs.mkdirSync(altDir, { recursive: true });
|
||||
const ssCompiled = compileAlternativePage(screenSharingTemplate, locale, lang, '../', '');
|
||||
fs.writeFileSync(path.join(altDir, 'screen-sharing.html'), ssCompiled);
|
||||
}
|
||||
if (hasOverviewTemplate) {
|
||||
const altDir = path.join(wwwDir, 'alternatives');
|
||||
fs.mkdirSync(altDir, { recursive: true });
|
||||
const ovCompiled = compileAlternativePage(overviewTemplate, locale, lang, '../', '');
|
||||
fs.writeFileSync(path.join(altDir, 'index.html'), ovCompiled);
|
||||
}
|
||||
} else {
|
||||
console.log(`Compiling ${lang.toUpperCase()} (${lang}/index.html)...`);
|
||||
const langDir = path.join(wwwDir, lang);
|
||||
@@ -200,6 +269,26 @@ async function compile() {
|
||||
const html = compilePage(locale, '../', lang);
|
||||
fs.writeFileSync(path.join(langDir, 'index.html'), html);
|
||||
englishHtml[lang] = html;
|
||||
|
||||
// Compile alternatives for other languages (assets resolved using '../../' prefix)
|
||||
if (hasTelepartyTemplate) {
|
||||
const langAltDir = path.join(langDir, 'alternatives');
|
||||
fs.mkdirSync(langAltDir, { recursive: true });
|
||||
const tpCompiled = compileAlternativePage(telepartyTemplate, locale, lang, '../../', lang + '/');
|
||||
fs.writeFileSync(path.join(langAltDir, 'teleparty.html'), tpCompiled);
|
||||
}
|
||||
if (hasScreenSharingTemplate) {
|
||||
const langAltDir = path.join(langDir, 'alternatives');
|
||||
fs.mkdirSync(langAltDir, { recursive: true });
|
||||
const ssCompiled = compileAlternativePage(screenSharingTemplate, locale, lang, '../../', lang + '/');
|
||||
fs.writeFileSync(path.join(langAltDir, 'screen-sharing.html'), ssCompiled);
|
||||
}
|
||||
if (hasOverviewTemplate) {
|
||||
const langAltDir = path.join(langDir, 'alternatives');
|
||||
fs.mkdirSync(langAltDir, { recursive: true });
|
||||
const ovCompiled = compileAlternativePage(overviewTemplate, locale, lang, '../../', lang + '/');
|
||||
fs.writeFileSync(path.join(langAltDir, 'index.html'), ovCompiled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +308,7 @@ async function compile() {
|
||||
const src = path.join(websiteDir, mapping.src);
|
||||
const dest = path.join(wwwDir, mapping.dest);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log(` Copied: ${mapping.src} → ${mapping.dest}`);
|
||||
}
|
||||
@@ -278,23 +368,15 @@ async function compile() {
|
||||
let html = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// 8a. Replace hashed asset refs
|
||||
html = html.replace(/href="(?:\.\.\/)?style\.min\.css"/g, (m) => {
|
||||
const prefix = m.includes('../') ? '../' : '';
|
||||
html = html.replace(/href="((?:\.\.\/)*)style\.min\.css"/g, (m, prefix) => {
|
||||
return `href="${prefix}${styleName}"`;
|
||||
});
|
||||
html = html.replace(/src="(?:\.\.\/)?app\.min\.js"/g, (m) => {
|
||||
const prefix = m.includes('../') ? '../' : '';
|
||||
html = html.replace(/src="((?:\.\.\/)*)app\.min\.js"/g, (m, prefix) => {
|
||||
return `src="${prefix}${appName}"`;
|
||||
});
|
||||
html = html.replace(/src="(?:\.\.\/)?lang-init\.min\.js"/g, (m) => {
|
||||
const prefix = m.includes('../') ? '../' : '';
|
||||
html = html.replace(/src="((?:\.\.\/)*)lang-init\.min\.js"/g, (m, prefix) => {
|
||||
return `src="${prefix}${langName}"`;
|
||||
});
|
||||
// Also update preload directives
|
||||
html = html.replace(/href="(?:\.\.\/)?style\.min\.css"/g, (m) => {
|
||||
const prefix = m.includes('../') ? '../' : '';
|
||||
return `href="${prefix}${styleName}"`;
|
||||
});
|
||||
|
||||
// 8b. Inject AVIF <picture> wrappers
|
||||
html = injectAvifPictures(html);
|
||||
@@ -7,6 +7,11 @@
|
||||
<link rel="preload" href="../style.min.css" as="style">
|
||||
<link rel="stylesheet" href="../style.min.css">
|
||||
<link rel="icon" type="image/webp" href="../assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/de/datenschutz">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/privacy">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/datenschutz">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/privacy">
|
||||
<meta name="description" content="Datenschutzerklärung von KoalaSync - Wir nehmen Datenschutz ernst. Erfahre mehr über unseren Ansatz ohne Tracking, ohne Logs und ohne Cookies.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
@@ -155,6 +160,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="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives/teleparty">Vergleiche & Anleitungen</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<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
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<link rel="preload" href="../style.min.css" as="style">
|
||||
<link rel="stylesheet" href="../style.min.css">
|
||||
<link rel="icon" type="image/webp" href="../assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
|
||||
<meta name="description" content="Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
@@ -142,6 +147,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="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives">Vergleiche & Anleitungen</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<link rel="preload" href="style.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
|
||||
<meta name="description" content="Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
@@ -140,6 +145,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="imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="alternatives">Guides & comparisons</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
|
||||
@@ -119,6 +119,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="impressum" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Legal Notice</span><span lang="de">Impressum</span></a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Privacy Policy</span><span lang="de">Datenschutz</span></a>
|
||||
<a href="alternatives"><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
|
||||
@@ -31,11 +31,15 @@
|
||||
'tr': 'tr',
|
||||
'nl': 'nl',
|
||||
'ja': 'ja',
|
||||
'ko': 'ko'
|
||||
'ko': 'ko',
|
||||
'zh': 'zh',
|
||||
'uk': 'uk'
|
||||
};
|
||||
|
||||
var getBrowserLang = function() {
|
||||
var fullLang = (navigator.language || '').toLowerCase();
|
||||
if (fullLang.indexOf('zh') === 0) return 'zh';
|
||||
if (fullLang.indexOf('uk') === 0) return 'uk';
|
||||
if (fullLang.indexOf('pt-br') === 0) return 'pt-br';
|
||||
if (fullLang.indexOf('pt') === 0) return 'pt';
|
||||
return fullLang.split('-')[0];
|
||||
|
||||
+32
-2
@@ -7,6 +7,7 @@
|
||||
"OG_LOCALE": "de_DE",
|
||||
"META_TITLE": "KoalaSync | Netflix, YouTube & jedes Video mit Freunden synchronisieren",
|
||||
"META_DESCRIPTION": "Schaue Netflix, YouTube, Twitch und jedes HTML5-Video synchron mit Freunden. Kostenlose, quelloffene Browser-Erweiterung. Keine Anmeldung erforderlich.",
|
||||
"SCHEMA_APP_DESC": "Schaue Netflix, YouTube, Twitch und jedes HTML5-Video synchron mit Freunden. Kostenlose, quelloffene Browser-Erweiterung. Keine Anmeldung erforderlich.",
|
||||
"OG_TITLE": "KoalaSync | Netflix, Emby, Jellyfin & fast jedes Video mit Freunden synchronisieren",
|
||||
"OG_DESCRIPTION": "Schaue Netflix, Emby, Jellyfin, YouTube, Twitch und fast jedes HTML5-Video perfekt synchronisiert. Quelloffene, datenschutzfreundliche Browser-Erweiterung für Chrome und Firefox.",
|
||||
"TWITTER_TITLE": "KoalaSync | Netflix, Emby, Jellyfin & fast jedes Video mit Freunden synchronisieren – Browser-Erweiterung",
|
||||
@@ -75,7 +76,7 @@
|
||||
"COMP_FEAT_5_TELE": "Nur unterstützte Seiten",
|
||||
"COMP_FEAT_6_NAME": "Lokalisierung / Übersetzung",
|
||||
"COMP_FEAT_6_DESC": "Verfügbare Oberflächensprachen und Übersetzungsunterstützung.",
|
||||
"COMP_FEAT_6_KOALA": "13 Sprachen (wachsend)",
|
||||
"COMP_FEAT_6_KOALA": "15 Sprachen (wachsend)",
|
||||
"COMP_FEAT_6_TELE": "Nur Englisch",
|
||||
"COMP_FEAT_7_NAME": "Audio Compressor / Lautstärke Angleichen",
|
||||
"COMP_FEAT_7_DESC": "Integrierte Audionormalisierung für leise Dialoge und laute Effekte",
|
||||
@@ -116,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Alles, was du über synchrones Videoschauen mit Freunden wissen musst.",
|
||||
"FAQ_Q1": "Kann ich Netflix, Emby oder Jellyfin synchron mit Freunden schauen?",
|
||||
"FAQ_A1": "Ja! KoalaSync funktioniert mit Netflix, YouTube, Twitch und jeder Website mit einem HTML5-Videoplayer — einschließlich Emby, Jellyfin und eigenen Medienservern. Installiere die Erweiterung, erstelle einen Raum, teile den Einladungslink und alle schauen perfekt synchron.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "Streamt KoalaSync das Video an meine Freunde?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Nein. KoalaSync streamt, hostet oder teilt keine Videoinhalte und umgeht keinen Zugang. Jede Person schaut das Video lokal im eigenen Browser und braucht selbst Zugriff auf die jeweilige Website oder den Streamingdienst, zum Beispiel ein eigenes Netflix-Konto. KoalaSync synchronisiert nur Aktionen wie Play, Pause und Spulen.",
|
||||
"FAQ_Q2": "Ist KoalaSync eine kostenlose Alternative zu Teleparty?",
|
||||
"FAQ_A2": "100 % kostenlos und Open Source unter der MIT-Lizenz. Keine Premium-Stufen, keine gesperrten Funktionen, keine Werbung. Anders als Teleparty läuft KoalaSyncs Relay-Server ausschließlich im RAM — ohne Datensammlung und ohne Benutzerkonten.",
|
||||
"FAQ_Q3": "Funktioniert KoalaSync mit Emby, Jellyfin und eigenen Medienservern?",
|
||||
@@ -175,5 +178,32 @@
|
||||
"FOOTER_LEGAL_LINK": "de/impressum",
|
||||
"FOOTER_PRIVACY_LINK": "de/datenschutz",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.",
|
||||
"FOOTER_SUPPORT": "Kauf mir einen Kaffee auf Ko-Fi"
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"ALT_TELEPARTY_TITLE": "KoalaSync vs Teleparty: Welche Watch-Party-Lösung passt besser?",
|
||||
"ALT_TELEPARTY_META_DESC": "Ein ehrlicher Vergleich von KoalaSync und Teleparty für Watch-Partys, eingebauten Chat, Mobile-Support, Browser-Video-Sync, selbst gehostete Medienserver und Open Source.",
|
||||
"ALT_TELEPARTY_INTRO": "KoalaSync und Teleparty lösen beide dasselbe nervige Problem: Du willst mit anderen Leuten online etwas schauen, ohne ständig „3, 2, 1, Play“ sagen zu müssen. Beide synchronisieren Videowiedergabe, aber sie gehen ziemlich unterschiedlich an die Sache ran.<br><br>Teleparty ist sinnvoll, wenn du einen eingebauten Chat möchtest oder am Handy schauen willst. KoalaSync ist eher für Desktop-Browser-Watch-Partys gedacht, bei denen du sowieso schon mit Freunden in Discord, Teamspeak oder einem anderen Voice-Call bist und einfach nur die Wiedergabe synchron halten möchtest.<br><br>Der Hauptgrund, warum ich KoalaSync gebaut habe, war Flexibilität. Es soll mit fast jeder Website funktionieren, die einen normalen Browser-Videoplayer nutzt, nicht nur mit einer festen Liste an Plattformen. Dazu gehören auch selbst gehostete Setups wie Emby, Jellyfin und Plex, lokale oder private Domains und viele normale Videoseiten im Browser.",
|
||||
"ALT_TELEPARTY_WHEN_TELE_TITLE": "Wann Teleparty trotzdem sinnvoller sein kann",
|
||||
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty kann trotzdem die bessere Wahl sein, wenn du den Chat direkt im Watch-Party-Tool haben möchtest. KoalaSync hat aktuell keinen eingebauten Chat. Es geht eher davon aus, dass du sowieso schon woanders mit deinen Freunden redest, zum Beispiel in Discord, Teamspeak, WhatsApp, Signal oder einem normalen Anruf.<br><br>Mobile ist der zweite große Unterschied. Teleparty hat eine App, während KoalaSync aktuell nur auf Desktop-Browsern läuft. Wenn du Netflix oder andere Inhalte am Handy oder Tablet synchron schauen möchtest, ist KoalaSync im Moment nicht das richtige Tool dafür.<br><br>Wenn dir also vor allem eingebauter Chat und Mobile-Support wichtig sind, passt Teleparty wahrscheinlich besser zu deinem Setup.",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "Wann KoalaSync besser passt",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_BODY": "KoalaSync passt besser, wenn du ein flexibles Sync-Tool für den Desktop-Browser suchst und nicht auf eine kurze Plattformliste beschränkt sein möchtest.<br><br>Es funktioniert mit fast jeder Website, die einen normalen Browser-Videoplayer nutzt. Das ist besonders nützlich für Emby, Jellyfin, Plex, private Medienserver, lokale Domains oder kleinere Videoseiten, die klassische Watch-Party-Tools meistens gar nicht beachten.<br><br>Außerdem ist KoalaSync kostenlos, Open Source, braucht keine Accounts und kann bei Bedarf selbst gehostet werden. Ich habe es gebaut, weil ich etwas Privacy-freundliches wollte, das mit meinem eigenen Emby-Setup funktioniert, nicht noch eine geschlossene Plattform nur für ein paar große Streaming-Seiten.",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "Der wichtigste Unterschied",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty ist eher ein Watch-Party-Produkt mit Chat und Mobile-Unterstützung. KoalaSync ist eher eine flexible Sync-Schicht für Browser-Video.<br><br>Das klingt erstmal nach einem kleinen Unterschied, ändert aber den Use Case ziemlich stark. Wenn du eine Watch-Party-App mit eingebautem Chat brauchst, ergibt Teleparty Sinn. Wenn du schon einen Voice-Call hast und nur möchtest, dass Play, Pause und Seek zwischen mehreren Browsern synchron bleiben, passt KoalaSync besser.",
|
||||
"ALT_TELEPARTY_LIMITS_TITLE": "Ehrliche Grenzen",
|
||||
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync ist nicht magisch und versucht auch nicht, seine Grenzen zu verstecken. Es funktioniert aktuell nur auf Desktop-Browsern. Es gibt keinen eingebauten Chat und keine Mobile-App.<br><br>Die Kompatibilität hängt außerdem vom Browser und vom jeweiligen Videoplayer ab. KoalaSync sollte mit fast jedem normalen Browser-Videoplayer funktionieren, aber Websites können ihre Player, DRM-Logik oder Browser-Einschränkungen jederzeit ändern.<br><br>KoalaSync ist nicht mit Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex oder anderen hier genannten Marken verbunden. Die Namen werden nur verwendet, um typische Anwendungsfälle verständlich zu beschreiben.",
|
||||
"ALT_TELEPARTY_SUMMARY_TITLE": "Kurz gesagt",
|
||||
"ALT_TELEPARTY_SUMMARY_BODY": "Wenn du eingebauten Chat oder Mobile-Support brauchst, passt Teleparty wahrscheinlich besser. Wenn du kostenlose, Open-Source-Desktop-Browser-Synchronisierung möchtest, die mit fast jedem normalen Videoplayer funktioniert, inklusive selbst gehosteter Medienserver wie Emby, Jellyfin und Plex, ist KoalaSync wahrscheinlich die passendere Wahl.",
|
||||
"ALT_SCREEN_TITLE": "KoalaSync vs Bildschirmübertragung: Wann ist Video-Sync besser?",
|
||||
"ALT_SCREEN_META_DESC": "Ein ehrlicher Vergleich von KoalaSync und Bildschirmübertragung für Filmabende, Wiedergabekontrolle, Videoqualität, Privatsphäre, Untertitel und gemeinsames Schauen im Browser.",
|
||||
"ALT_SCREEN_INTRO": "Bildschirmübertragung funktioniert, und manchmal reicht das auch völlig. Eine Person teilt den Bildschirm, alle anderen schauen zu, fertig.<br><br>Bei längeren Filmabenden oder Serien kann das aber schnell nervig werden. Die Qualität hängt vom Upload, der CPU, dem Browser und der Komprimierung durch Discord, Teamspeak, Zoom oder andere Call-Apps ab. Außerdem kontrolliert nur eine Person wirklich die Wiedergabe.",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_TITLE": "Wann Bildschirmübertragung reicht",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_BODY": "Screen Sharing ist sinnvoll, wenn es möglichst schnell gehen soll. Wenn nur eine Person Zugriff auf das Video hat oder alle damit okay sind, dass eine Person Play, Pause und Spulen kontrolliert, ist es wahrscheinlich die einfachste Lösung.<br><br>Es reicht auch oft, wenn die Qualität nicht so wichtig ist, niemand eigene Untertitel oder Audio-Einstellungen braucht und die Person, die teilt, kein Problem damit hat, genau dieses Fenster oder diesen Bildschirm zu zeigen.",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_TITLE": "Wo Bildschirmübertragung nervig wird",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_BODY": "Der größte Nachteil ist, dass nicht jeder im eigenen Player schaut. Eine Person streamt den Bildschirm, alle anderen sehen nur diesen Stream.<br><br>Das kann schlechtere Bildqualität, komprimierten Ton, Verzögerung und weniger Kontrolle für alle anderen bedeuten. Untertitel und Audiospur sind außerdem genau das, was die teilende Person eingestellt hat.<br><br>Dazu kommt der Privacy-Aspekt. Wenn du Fenster oder Tabs wechselst, können andere kurz deine Browser-Tabs, Mails, Reddit, Chats oder andere private Dinge sehen. Man kann das mit einem separaten Fenster umgehen, aber man muss trotzdem daran denken.",
|
||||
"ALT_SCREEN_WHY_CLEANER_TITLE": "Warum KoalaSync sauberer sein kann",
|
||||
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSync überträgt nicht deinen Bildschirm. Jeder schaut das Video lokal im eigenen Browser, und KoalaSync hält nur Aktionen wie Play, Pause und Spulen zwischen allen Teilnehmern synchron.<br><br>Dadurch kann jeder lokal Vollbild nutzen, eigene Untertitel oder Audio-Einstellungen verwenden, sofern der Player das unterstützt, und der Voice-Call bleibt separat in Discord, Teamspeak oder einer anderen App.<br><br>Für Filmabende fühlt sich das oft deutlich sauberer an, als wenn eine Person den ganzen Bildschirm streamt. Besonders praktisch ist das bei browserbasierten Setups wie Emby, Jellyfin, Plex, lokalen Dateien im Browser, privaten Domains oder anderen Websites mit normalen Browser-Videoplayern.",
|
||||
"ALT_SCREEN_LIMITS_TITLE": "Ehrliche Grenzen",
|
||||
"ALT_SCREEN_LIMITS_BODY": "KoalaSync ersetzt keinen Voice-Call. Wenn ihr beim Schauen reden wollt, nutzt ihr weiterhin Discord, Teamspeak, Signal, WhatsApp oder was auch immer ihr normalerweise verwendet.<br><br>Außerdem braucht jede Person selbst Zugriff auf das Video oder die jeweilige Seite. KoalaSync umgeht keine Abos, Logins, DRM, regionale Einschränkungen oder Plattformregeln.<br><br>KoalaSync ist aktuell Desktop-Browser-only, hat keinen eingebauten Chat oder Voice-Chat und die Funktion hängt immer vom jeweiligen Browser und Videoplayer ab.",
|
||||
"ALT_SCREEN_SUMMARY_TITLE": "Kurz gesagt",
|
||||
"ALT_SCREEN_SUMMARY_BODY": "Bildschirmübertragung ist die einfachste Lösung, wenn es schnell gehen soll und Qualität, Privatsphäre oder individuelle Kontrolle nicht so wichtig sind. KoalaSync passt besser, wenn jeder lokal im eigenen Browser schauen soll und sich der Filmabend mehr wie wirklich gemeinsames Schauen anfühlen soll.",
|
||||
"ALT_TRY_KOALASYNC": "KoalaSync ausprobieren"
|
||||
}
|
||||
|
||||
+32
-2
@@ -7,6 +7,7 @@
|
||||
"OG_LOCALE": "en_US",
|
||||
"META_TITLE": "KoalaSync | Sync Netflix, YouTube & Any Video with Friends",
|
||||
"META_DESCRIPTION": "Watch Netflix, YouTube, Twitch & any HTML5 video in perfect sync with friends. Free, open-source browser extension for Chrome and Firefox. No sign-up needed.",
|
||||
"SCHEMA_APP_DESC": "Watch Netflix, YouTube, Twitch & any HTML5 video in perfect sync with friends. Free, open-source browser extension for Chrome and Firefox. No sign-up needed.",
|
||||
"OG_TITLE": "KoalaSync | Sync Netflix, Emby, Jellyfin & Almost Any Video with Friends",
|
||||
"OG_DESCRIPTION": "Watch Netflix, Emby, Jellyfin, YouTube, Twitch and almost any HTML5 video in perfect sync. Privacy-first, open-source browser extension for Chrome & Firefox.",
|
||||
"TWITTER_TITLE": "KoalaSync | Sync Netflix, Emby, Jellyfin & Almost Any Video with Friends – Browser Extension",
|
||||
@@ -75,7 +76,7 @@
|
||||
"COMP_FEAT_5_TELE": "Supported sites only",
|
||||
"COMP_FEAT_6_NAME": "Localization / Translation",
|
||||
"COMP_FEAT_6_DESC": "Available interface languages and translation support.",
|
||||
"COMP_FEAT_6_KOALA": "13 Languages (and growing)",
|
||||
"COMP_FEAT_6_KOALA": "15 Languages (and growing)",
|
||||
"COMP_FEAT_6_TELE": "English only",
|
||||
"COMP_FEAT_7_NAME": "Audio Compressor / Volume Leveling",
|
||||
"COMP_FEAT_7_DESC": "Built-in audio normalization to balance quiet dialogue and loud effects",
|
||||
@@ -116,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Everything you need to know about watching videos together in perfect sync.",
|
||||
"FAQ_Q1": "Can I watch Netflix, Emby or Jellyfin together in sync with friends?",
|
||||
"FAQ_A1": "Yes! KoalaSync works on Netflix, YouTube, Twitch and any website with an HTML5 video player — including Emby, Jellyfin and self-hosted media servers. Install the extension, create a room, share the invite link, and everyone watches in perfect sync.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "Does KoalaSync stream the video to my friends?",
|
||||
"FAQ_A_VIDEO_ACCESS": "No. KoalaSync does not stream, host, share, or bypass access to video content. Everyone watches the video locally in their own browser and needs their own access to the website or streaming service, such as their own Netflix account. KoalaSync only synchronizes playback actions like play, pause, and seeking.",
|
||||
"FAQ_Q2": "Is KoalaSync a free alternative to Teleparty?",
|
||||
"FAQ_A2": "100% free and open source under the MIT License. No premium tiers, no locked features, no ads — everything is included. Unlike Teleparty, KoalaSync's relay server is RAM-only with zero data collection or user accounts required.",
|
||||
"FAQ_Q3": "Does KoalaSync work with Emby, Jellyfin and self-hosted media servers?",
|
||||
@@ -175,5 +178,32 @@
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.",
|
||||
"FOOTER_SUPPORT": "Buy me a coffee on Ko-Fi"
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"ALT_TELEPARTY_TITLE": "KoalaSync vs Teleparty: which watch party tool should you use?",
|
||||
"ALT_TELEPARTY_META_DESC": "A practical comparison of KoalaSync and Teleparty for watch parties, built-in chat, mobile support, browser video sync, self-hosted media servers and open-source use.",
|
||||
"ALT_TELEPARTY_INTRO": "KoalaSync and Teleparty both help with the same annoying problem: watching something together without doing the whole \"3, 2, 1, play\" routine. They just take a different approach.<br><br>Teleparty makes the most sense if you want a built-in chat or if you want to watch on mobile. KoalaSync is more for desktop browser watch parties where you are already talking on Discord, Teamspeak or another voice call and just want playback to stay in sync.<br><br>The main reason I built KoalaSync was flexibility. It is meant to work with almost any website that uses a normal browser video player, not just a fixed list of platforms. That includes self-hosted setups like Emby, Jellyfin and Plex, local/private domains, and a lot of normal video pages in the browser.",
|
||||
"ALT_TELEPARTY_WHEN_TELE_TITLE": "When Teleparty may still make sense",
|
||||
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty can still be the better choice if you want the chat to be part of the watch party tool itself. KoalaSync does not currently have a built-in chat. It assumes you are already talking somewhere else, like Discord, Teamspeak, WhatsApp, Signal or a normal call.<br><br>Mobile is the other big reason. Teleparty has an app, while KoalaSync currently only works on desktop browsers. If you want to sync Netflix on a phone or tablet, KoalaSync is not the right tool for that right now.<br><br>So if chat and mobile support are the things you care about most, Teleparty may simply fit your setup better.",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "When KoalaSync may fit better",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_BODY": "KoalaSync fits better if you want a flexible desktop browser sync tool instead of something tied to a short platform list.<br><br>It works with almost any website that uses a normal browser video player. That is especially useful for Emby, Jellyfin, Plex, private media servers, local domains, or smaller video sites that classic watch party tools usually ignore.<br><br>It is also free, open source, does not require accounts, and can be self-hosted if you want to run your own relay. I built it because I wanted something privacy-friendly that worked with my own Emby setup, not another closed platform just for a few big streaming sites.",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "The main difference",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty is more of a watch party product with chat and mobile support. KoalaSync is more of a flexible sync layer for browser video.<br><br>That sounds like a small difference, but it changes the use case a lot. If you need a full watch party app with chat inside it, Teleparty makes sense. If you already have voice chat and just want the video controls to stay synced across browsers, KoalaSync is the simpler fit.",
|
||||
"ALT_TELEPARTY_LIMITS_TITLE": "Honest limitations",
|
||||
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync is not magic and it is not trying to hide its limits. It currently works only on desktop browsers. It has no built-in chat and no mobile app.<br><br>Compatibility also depends on the browser and the video player. KoalaSync should work with almost any normal browser video player, but websites can change their players, DRM behavior or browser restrictions at any time.<br><br>KoalaSync is not affiliated with Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex or any other brand mentioned here. The names are only used to explain typical use cases.",
|
||||
"ALT_TELEPARTY_SUMMARY_TITLE": "Summary",
|
||||
"ALT_TELEPARTY_SUMMARY_BODY": "If you want built-in chat or mobile support, Teleparty may be the better fit. If you want free, open-source desktop browser sync that works with almost any normal video player, including self-hosted media servers like Emby, Jellyfin and Plex, KoalaSync is probably the better match.",
|
||||
"ALT_SCREEN_TITLE": "KoalaSync vs screen sharing: when is video sync better?",
|
||||
"ALT_SCREEN_META_DESC": "A practical comparison of KoalaSync and screen sharing for remote movie nights, playback control, video quality, privacy, subtitles and browser-based watching.",
|
||||
"ALT_SCREEN_INTRO": "Screen sharing works, and sometimes it is honestly enough. One person shares their screen, everyone else watches, and you are done.<br><br>But for longer movie nights or watching episodes together, it can get annoying. The video quality depends on the sharer\u2019s upload, CPU, browser, and whatever compression Discord, Teamspeak, Zoom or another call app applies. Also, only one person really controls the playback.",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_TITLE": "When screen sharing is enough",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_BODY": "Screen sharing is fine when you want the fastest possible setup. If only one person has access to the video, or everyone is okay with one person controlling play, pause and seeking, it may be the simplest option.<br><br>It also makes sense when quality is not a big deal, when nobody needs their own subtitles or audio settings, and when the person sharing is comfortable showing that browser window or screen.",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_TITLE": "Where screen sharing gets annoying",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_BODY": "The biggest downside is that not everyone is watching in their own player. One person streams their screen, and everyone else only sees that stream.<br><br>That can mean worse video quality, compressed audio, delayed playback, and less control for everyone else. Subtitles and audio tracks are also whatever the sharer picked.<br><br>There is also the privacy side. If you switch windows or tabs, people may briefly see your browser tabs, email, Reddit, chats or other private stuff. You can work around that with a separate window, but it is still something you have to think about.",
|
||||
"ALT_SCREEN_WHY_CLEANER_TITLE": "Why KoalaSync can feel cleaner",
|
||||
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSync does not stream your screen. Everyone watches the video locally in their own browser, and KoalaSync only keeps playback actions like play, pause and seeking in sync.<br><br>That means everyone can use fullscreen on their own device, use their own subtitle or audio settings if the player supports it, and keep the voice chat separate in Discord, Teamspeak or another call.<br><br>For movie nights, that can feel a lot cleaner than one person broadcasting their screen. It is especially useful for browser-based setups like Emby, Jellyfin, Plex, local files opened in the browser, private domains, or other websites with normal browser video players.",
|
||||
"ALT_SCREEN_LIMITS_TITLE": "Honest limitations",
|
||||
"ALT_SCREEN_LIMITS_BODY": "KoalaSync is not a replacement for a voice call. If you want to talk while watching, you still use Discord, Teamspeak, Signal, WhatsApp or whatever you normally use.<br><br>Everyone also needs access to the video or page on their own device. KoalaSync does not bypass subscriptions, logins, DRM, regional restrictions or platform rules.<br><br>It is currently desktop-browser only, has no built-in chat or voice feature, and compatibility always depends on the browser and the video player.",
|
||||
"ALT_SCREEN_SUMMARY_TITLE": "Summary",
|
||||
"ALT_SCREEN_SUMMARY_BODY": "Screen sharing is the simplest option when you just want something quick and do not care much about quality, privacy or individual control. KoalaSync is a better fit when everyone should watch locally in their own browser and the session should feel more like actually watching together.",
|
||||
"ALT_TRY_KOALASYNC": "Try KoalaSync"
|
||||
}
|
||||
|
||||
+115
-85
@@ -6,7 +6,8 @@
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "es_ES",
|
||||
"META_TITLE": "KoalaSync | Sincroniza Netflix, YouTube y cualquier video con amigos",
|
||||
"META_DESCRIPTION": "Mira Netflix, YouTube, Twitch y cualquier video HTML5 en perfecta sincronización con amigos. Extensión de navegador gratuita y de código abierto. Sin registro.",
|
||||
"META_DESCRIPTION": "Mira Netflix, YouTube, Twitch y cualquier video HTML5 en perfecta sincronización con amigos. Extensión de navegador gratuita, de código abierto y sin necesidad de registro.",
|
||||
"SCHEMA_APP_DESC": "Mira Netflix, YouTube, Twitch y cualquier video HTML5 en perfecta sincronización con amigos. Extensión de navegador gratuita, de código abierto y sin necesidad de registro.",
|
||||
"OG_TITLE": "KoalaSync | Sincroniza Netflix, Emby, Jellyfin y casi cualquier video con amigos",
|
||||
"OG_DESCRIPTION": "Mira Netflix, Emby, Jellyfin, YouTube, Twitch y casi cualquier video HTML5 en perfecta sincronización. Extensión de navegador de código abierto y respetuosa con la privacidad para Chrome y Firefox.",
|
||||
"TWITTER_TITLE": "KoalaSync | Sincroniza Netflix, Emby, Jellyfin y casi cualquier video con amigos – Extensión de navegador",
|
||||
@@ -14,7 +15,7 @@
|
||||
"NAV_FEATURES": "Características",
|
||||
"NAV_HOW_IT_WORKS": "Cómo funciona",
|
||||
"HERO_TITLE": "Miren juntos.<span class='hero-line2'>Sincronización perfecta.</span>",
|
||||
"HERO_SUBTITLE": "Tu noche de películas a distancia sin retrasos. Sin registro ni recopilación de datos. Solo comparte un enlace y miren juntos.",
|
||||
"HERO_SUBTITLE": "Tu noche de películas a distancia sin retrasos. Sin registros ni recopilación de datos. Solo comparte un enlace y miren juntos.",
|
||||
"HERO_MASCOT_ALT": "Un lindo koala de pie mirando hacia abajo a los botones de descarga",
|
||||
"ADD_TO_CHROME": "Añadir a Chrome",
|
||||
"ADD_TO_FIREFOX": "Añadir a Firefox",
|
||||
@@ -25,40 +26,40 @@
|
||||
"USE_CASES_SUBTITLE": "Ya sea cerca o lejos, KoalaSync une a las personas en torno a sus videos favoritos.",
|
||||
"USE_CASE_1_ALT": "Dos lindos koalas sentados juntos compartiendo un cubo de palomitas de maíz",
|
||||
"USE_CASE_1_TITLE": "Noche de películas con amigos",
|
||||
"USE_CASE_1_DESC": "Sincroniza tus películas en tiempo real y habla por Discord, Zoom o tu aplicación de llamada de voz favorita. Es como estar en la misma habitación.",
|
||||
"USE_CASE_1_DESC": "Sincroniza tus películas en tiempo real y habla por Discord, Zoom o tu aplicación de voz favorita. Es como estar en la misma habitación.",
|
||||
"USE_CASE_2_ALT": "Un profesor koala presentando en una pantalla a dos estudiantes koala remotos",
|
||||
"USE_CASE_2_TITLE": "Aprendizaje a distancia",
|
||||
"USE_CASE_2_DESC": "Analiza tutoriales en línea, conferencias o transmisiones de capacitación de desarrolladores con compañeros o colegas. Pausa y discute pasos complejos en perfecta armonía.",
|
||||
"USE_CASE_2_DESC": "Analiza tutoriales en línea, conferencias o directos de programación con compañeros o colegas. Pausa y discute pasos complejos en perfecta armonía.",
|
||||
"USE_CASE_3_ALT": "Una linda pareja de koalas viendo juntos de forma remota; uno está sentado frente a una PC y el otro en la cama con una computadora portátil, con corazones volando entre ellos",
|
||||
"USE_CASE_3_TITLE": "Relaciones a larga distancia",
|
||||
"USE_CASE_3_DESC": "Reduce la distancia y disfruta de noches de citas. Experimenta cada giro de la trama, ríete de los mismos chistes y comparte momentos emotivos en el mismo milisegundo exacto.",
|
||||
"USE_CASE_3_DESC": "Reduce la distancia y disfruta de noches de citas. Vive cada giro de la trama, ríete de los mismos chistes y comparte momentos emotivos en el mismo milisegundo exacto.",
|
||||
"WHY_TITLE": "¿Por qué KoalaSync?",
|
||||
"WHY_SUBTITLE": "Diseñado para una sincronización confiable, privacidad y una configuración sencilla.",
|
||||
"WHY_SUBTITLE": "Diseñado para una sincronización confiable, con la privacidad como prioridad y una configuración sencilla.",
|
||||
"FEATURE_1_TITLE": "Control total / Sincronización instantánea",
|
||||
"FEATURE_1_DESC": "Uno pausa, todos pausan. Uno avanza, todos siguen. Nuestro protocolo de sincronización en dos fases coordina la reproducción en tiempo real para todos los participantes.",
|
||||
"FEATURE_1_DESC": "Si uno pausa, todos pausan. Si uno avanza, todos le siguen. Nuestro protocolo de sincronización en dos fases coordina la reproducción en tiempo real para todos los participantes.",
|
||||
"FEATURE_2_TITLE": "Maratones sin fin",
|
||||
"FEATURE_2_DESC": "Reproducción automática sincronizada. KoalaSync detecta automáticamente transiciones de episodios y suspende la reproducción hasta que todos los participantes hayan cargado el siguiente video.",
|
||||
"FEATURE_2_DESC": "Reproducción automática sincronizada. KoalaSync detecta automáticamente los cambios de episodio y detiene la reproducción hasta que todos los participantes hayan cargado el siguiente video.",
|
||||
"FEATURE_3_TITLE": "Sin cuentas / Privacidad absoluta",
|
||||
"FEATURE_3_DESC": "Sin registros, sin seguimiento, sin almacenamiento de datos. El servidor funciona completamente en RAM efímera y elimina tu sala tan pronto como sales.",
|
||||
"FEATURE_3_DESC": "Sin registros, sin seguimiento y sin dejar huella. El servidor funciona completamente en RAM efímera y elimina tu sala en cuanto te vas.",
|
||||
"FEATURE_4_TITLE": "Soporte universal HTML5",
|
||||
"FEATURE_4_DESC": "Funciona en YouTube, Twitch, Netflix, Jellyfin, Emby y casi cualquier página web estándar que contenga un elemento de video HTML5. También es compatible con configuraciones personalizadas. Nunca se envían datos de vídeo a nuestros servidores, solo metadatos de sincronización.",
|
||||
"FEATURE_4_DESC": "Funciona en YouTube, Twitch, Netflix, Jellyfin, Emby y casi cualquier página web estándar con un reproductor HTML5. También es compatible con configuraciones propias. Nunca enviamos datos de vídeo a nuestros servidores, solo metadatos de sincronización.",
|
||||
"FEATURE_5_TITLE": "Auto-alojable y listo para Docker",
|
||||
"FEATURE_5_DESC": "Nuestros servidores oficiales son gratuitos y rápidos, pero puedes tomar el control total si lo deseas. Inicia tu propio servidor de retransmisión privado en segundos a través de Docker.",
|
||||
"FEATURE_6_TITLE": "Invitaciones al instante / Unirse en 1 clic",
|
||||
"FEATURE_6_DESC": "Sin direcciones IP o contraseñas que intercambiar. Comparte un enlace de invitación generado para permitir que tus amigos se unan automáticamente a tu sala con un solo clic.",
|
||||
"FEATURE_5_DESC": "Nuestros servidores oficiales son gratuitos y rápidos, pero puedes tomar el control total si lo deseas. Inicia tu propio servidor de retransmisión privado en segundos mediante Docker.",
|
||||
"FEATURE_6_TITLE": "Invitaciones al instante / Unirse con un clic",
|
||||
"FEATURE_6_DESC": "Sin intercambio de direcciones IP o contraseñas. Comparte un enlace de invitación generado para que tus amigos se unan automáticamente a tu sala con un solo clic.",
|
||||
"FEATURE_7_TITLE": "Nivelación de volumen / Compresor de audio",
|
||||
"FEATURE_7_DESC": "No más usar el control de volumen constantemente. El compresor de audio integrado realza los diálogos bajos y controla las explosiones fuertes. Funciona en Netflix, Prime Video, Disney+, YouTube y cualquier video HTML5. Completamente local — ningún dato de audio sale de tu ordenador.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"FEATURE_7_DESC": "Se acabó el tener que ajustar el volumen constantemente. El compresor de audio integrado realza los diálogos bajos y suaviza las explosiones fuertes. Funciona en Netflix, Prime Video, Disney+, YouTube y cualquier video HTML5. Funciona de forma local: ningún dato de audio sale de tu equipo.",
|
||||
"FEATURE_8_TITLE": "Código Abierto (Licencia MIT)",
|
||||
"FEATURE_8_DESC": "Todo el código fuente es público y auditable en GitHub. Sin cajas negras ni software propietario: transparencia total desde el servidor de retransmisión hasta la extensión del navegador.",
|
||||
"COMP_TITLE": "KoalaSync vs Teleparty",
|
||||
"COMP_SUBTITLE": "Descubre por qué una herramienta de código abierto, sin anuncios y respetuosa con la privacidad es la mejor opción para ver juntos.",
|
||||
"COMP_SUBTITLE": "Descubre por qué una herramienta de código abierto, sin anuncios y privada es la mejor opción para ver contenido juntos.",
|
||||
"COMP_COL_FEATURE": "Característica",
|
||||
"COMP_FEAT_1_NAME": "Costo / Funciones de pago",
|
||||
"COMP_FEAT_1_NAME": "Coste / Funciones de pago",
|
||||
"COMP_FEAT_1_DESC": "Suscripciones premium, tarifas ocultas o funciones bloqueadas.",
|
||||
"COMP_FEAT_1_KOALA": "100% Gratis",
|
||||
"COMP_FEAT_1_TELE": "Suscripciones de pago / Bloqueos Premium",
|
||||
"COMP_FEAT_2_NAME": "Licence / Code auditable",
|
||||
"COMP_FEAT_2_DESC": "Si el código fuente es abierto y libremente auditable.",
|
||||
"COMP_FEAT_1_TELE": "Niveles de pago / Funciones Premium",
|
||||
"COMP_FEAT_2_NAME": "Licencia / Código auditable",
|
||||
"COMP_FEAT_2_DESC": "Indica si el código fuente es abierto y libremente auditable.",
|
||||
"COMP_FEAT_2_KOALA": "Código Abierto (MIT)",
|
||||
"COMP_FEAT_2_TELE": "Propietario",
|
||||
"COMP_FEAT_3_NAME": "Auto-alojamiento",
|
||||
@@ -69,111 +70,140 @@
|
||||
"COMP_FEAT_4_DESC": "Requisitos de registro, cookies de seguimiento y análisis.",
|
||||
"COMP_FEAT_4_KOALA": "Sin persistencia (solo RAM)",
|
||||
"COMP_FEAT_4_TELE": "Google Analytics y Cookies",
|
||||
"COMP_FEAT_5_NAME": "Compatibilidad del sitio",
|
||||
"COMP_FEAT_5_NAME": "Compatibilidad de sitios",
|
||||
"COMP_FEAT_5_DESC": "Sitios web compatibles y compatibilidad de reproductores.",
|
||||
"COMP_FEAT_5_KOALA": "Casi cualquier video HTML5",
|
||||
"COMP_FEAT_5_TELE": "Solo sitios compatibles",
|
||||
"COMP_FEAT_6_NAME": "Localización / Traducción",
|
||||
"COMP_FEAT_6_DESC": "Idiomas de interfaz disponibles y soporte de traducción.",
|
||||
"COMP_FEAT_6_KOALA": "13 idiomas (y creciendo)",
|
||||
"COMP_FEAT_6_KOALA": "15 idiomas (y sumando)",
|
||||
"COMP_FEAT_6_TELE": "Solo inglés",
|
||||
"COMP_FEAT_7_NAME": "Compresor de Audio / Nivelación de Volumen",
|
||||
"COMP_FEAT_7_DESC": "Normalización de audio integrada para equilibrar diálogos bajos y efectos fuertes",
|
||||
"COMP_FEAT_7_KOALA": "Sí (4 ajustes preestablecidos, control dry/wet)",
|
||||
"COMP_FEAT_7_KOALA": "Sí (4 ajustes, control dry/wet)",
|
||||
"COMP_FEAT_7_TELE": "No",
|
||||
"COMP_FOOTNOTE_1": "Estado de la comparación: mayo de 2026.",
|
||||
"COMP_FOOTNOTE_2": "Detalles oficiales de precios de Teleparty Premium y redes compatibles:",
|
||||
"COMP_FOOTNOTE_3": "Políticas de privacidad y recopilación de datos de seguimiento de Teleparty:",
|
||||
"COMP_FOOTNOTE_4": "Funciona en sitios web que permiten inyecciones de scripts en etiquetas de video HTML5 estándar. Los sitios con políticas de seguridad de contenido (CSP) muy estrictas, protección de copia DRM o contenedores de reproductores fuertemente ocultos (como DOMs de sombra complejos) pueden restringir el control automatizado o la inyección.",
|
||||
"COMP_FOOTNOTE_3": "Políticas de privacidad y recopilación de datos de Teleparty:",
|
||||
"COMP_FOOTNOTE_4": "Funciona en sitios web que permiten inyecciones de scripts en etiquetas de video HTML5 estándar. Los sitios con políticas de seguridad (CSP) muy estrictas, protección DRM o reproductores ofuscados (como DOMs de sombra complejos) pueden restringir el control automático.",
|
||||
"STEPS_TITLE": "Cómo empezar",
|
||||
"STEP_1_TITLE": "Instalar la extensión",
|
||||
"STEP_1_DESC": "Añade KoalaSync a tu navegador desde Chrome Web Store, complementos de Firefox o descarga el último archivo ZIP de desarrollador desde GitHub.",
|
||||
"STEP_1_ILLUS_DESC": "Sincronizador de video respetuoso con la privacidad",
|
||||
"STEP_1_DESC": "Añade KoalaSync a tu navegador desde la Chrome Web Store, complementos de Firefox o descarga el archivo ZIP para desarrolladores desde GitHub.",
|
||||
"STEP_1_ILLUS_DESC": "Sincronizador de video privado",
|
||||
"STEP_1_ILLUS_DL_CHROME": "Descargar para Chrome",
|
||||
"STEP_1_ILLUS_DL_FIREFOX": "Descargar para Firefox",
|
||||
"STEP_1_ILLUS_ACTIVE": "Extensión activa",
|
||||
"STEP_1_ILLUS_READY": "¡Listo para ver juntos!",
|
||||
"STEP_2_TITLE": "Crear una sala",
|
||||
"STEP_2_DESC": "Abre la ventana de la extensión y haz clic en '+ Crear nueva sala'. KoalaSync genera automáticamente un identificador de sala y una contraseña seguros, se conecta y copia el enlace de invitación a tu portapapeles.",
|
||||
"STEP_2_DESC": "Abre el menú de la extensión y haz clic en '+ Crear nueva sala'. KoalaSync generará automáticamente un ID de sala y una contraseña seguros, se conectará y copiará el enlace al portapapeles.",
|
||||
"STEP_2_ILLUS_ROOM": "Sala",
|
||||
"STEP_2_ILLUS_SYNC": "Sincro",
|
||||
"STEP_2_ILLUS_SETTINGS": "Opciones",
|
||||
"STEP_2_ILLUS_SETTINGS": "Ajustes",
|
||||
"STEP_2_ILLUS_CREATE": "+ Crear nueva sala",
|
||||
"STEP_2_ILLUS_MANUAL": "Conexión manual / Avanzado",
|
||||
"STEP_2_ILLUS_COPIED": "¡Enlace de invitación copiado!",
|
||||
"STEP_3_TITLE": "Compartir y Sincronizar",
|
||||
"STEP_3_DESC": "Envía el enlace de invitación a tus amigos. Tan pronto como se unan, selecciona tu pestaña de video y disfruta de la reproducción sincronizada.",
|
||||
"STEP_3_DESC": "Envía el enlace de invitación a tus amigos. Una vez que se unan, selecciona tu pestaña de video y disfrutad de la reproducción sincronizada.",
|
||||
"STEP_3_ILLUS_IN_SYNC": "SINCRONIZADO",
|
||||
"SELF_TITLE": "Para auto-alojadores",
|
||||
"SELF_SUBTITLE": "¿No confías en nuestro servidor? Mantén la soberanía total de los datos. Despliega tu propio servidor de retransmisión en minutos.",
|
||||
"SELF_MASCOT_ALT": "Un lindo koala sentado frente a una computadora portátil desplegando un contenedor Docker para auto-alojamiento",
|
||||
"SELF_SUBTITLE": "¿No te fías de nuestro servidor? Mantén la soberanía total de tus datos. Despliega tu propio servidor de retransmisión en minutos.",
|
||||
"SELF_MASCOT_ALT": "Un lindo koala sentado frente a un portátil desplegando un contenedor Docker",
|
||||
"SELF_COPY_CODE": "Copiar código",
|
||||
"SELF_GITHUB_PACKAGES": "Ver todas las etiquetas de imágenes en GitHub Packages",
|
||||
"SELF_GITHUB_PACKAGES": "Ver todas las versiones en GitHub Packages",
|
||||
"BOTTOM_TITLE": "¿Aún no te convence? Compruébalo tú mismo.",
|
||||
"BOTTOM_SUBTITLE": "KoalaSync es 100% de código abierto, sin anuncios y sin seguimiento. Audita nuestra base de código en GitHub o instala la extensión del navegador directamente.",
|
||||
"BOTTOM_MASCOT_ALT": "Un lindo koala sosteniendo una página de repositorio de GitHub junto a Octocat, la mascota de GitHub",
|
||||
"BOTTOM_SUBTITLE": "KoalaSync es 100% de código abierto, sin anuncios y sin rastreadores. Audita nuestro código en GitHub o instala la extensión directamente.",
|
||||
"BOTTOM_MASCOT_ALT": "Un lindo koala junto a Octocat, la mascota de GitHub",
|
||||
"FAQ_TITLE": "Preguntas frecuentes",
|
||||
"FAQ_SUBTITLE": "Todo lo que necesitas saber para ver videos juntos en perfecta sincronía.",
|
||||
"FAQ_SUBTITLE": "Todo lo que necesitas saber para ver vídeos en perfecta sincronía.",
|
||||
"FAQ_Q1": "¿Puedo ver Netflix, Emby o Jellyfin sincronizado con amigos?",
|
||||
"FAQ_A1": "¡Sí! KoalaSync funciona con Netflix, YouTube, Twitch y cualquier sitio con reproductor de video HTML5 — incluyendo Emby, Jellyfin y servidores multimedia propios. Instala la extensión, crea una sala, comparte el enlace y todos miran en perfecta sincronía.",
|
||||
"FAQ_A1": "¡Sí! KoalaSync funciona en Netflix, YouTube, Twitch y cualquier web con reproductor HTML5, incluyendo Emby, Jellyfin y servidores multimedia propios. Instala la extensión, crea una sala, comparte el enlace y todos veréis lo mismo al mismo tiempo.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "¿KoalaSync transmite el video a mis amigos?",
|
||||
"FAQ_A_VIDEO_ACCESS": "No. KoalaSync no transmite, aloja, comparte ni evita el acceso al contenido de video. Cada persona ve el video localmente en su propio navegador y necesita su propio acceso al sitio web o servicio de streaming, por ejemplo su propia cuenta de Netflix. KoalaSync solo sincroniza acciones de reproducción como reproducir, pausar y avanzar o retroceder.",
|
||||
"FAQ_Q2": "¿Es KoalaSync una alternativa gratuita a Teleparty?",
|
||||
"FAQ_A2": "100% gratuito y de código abierto bajo licencia MIT. Sin niveles premium, sin funciones bloqueadas, sin anuncios. A diferencia de Teleparty, el servidor relay de KoalaSync solo usa RAM — sin recolección de datos ni cuentas de usuario.",
|
||||
"FAQ_Q3": "¿Funciona KoalaSync con Emby, Jellyfin y servidores multimedia propios?",
|
||||
"FAQ_A3": "Sí. KoalaSync detecta cualquier elemento de video HTML5, por lo que funciona con Emby, Jellyfin, Plex y cualquier configuración de streaming personalizada. También puedes alojar tu propio servidor relay con Docker.",
|
||||
"FAQ_Q4": "¿Qué navegadores y plataformas son compatibles con KoalaSync?",
|
||||
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave y todos los navegadores basados en Chromium. Instálalo desde Chrome Web Store o Firefox Add-ons.",
|
||||
"FAQ_Q5": "¿Todos mis amigos necesitan instalar la extensión?",
|
||||
"FAQ_A5": "Sí, cada participante necesita KoalaSync — pero sin registro ni creación de cuenta. Solo haz clic en el enlace de invitación, instala la extensión si es necesario y te unes automáticamente a la sala.",
|
||||
"FAQ_A2": "Es 100% gratuito y de código abierto (Licencia MIT). Sin suscripciones, sin funciones bloqueadas y sin anuncios. A diferencia de Teleparty, el servidor de KoalaSync solo usa RAM: no hay bases de datos ni cuentas de usuario.",
|
||||
"FAQ_Q3": "¿Funciona KoalaSync con Emby, Jellyfin y servidores propios?",
|
||||
"FAQ_A3": "Sí. KoalaSync detecta cualquier elemento de video HTML5 estándar, por lo que es perfecto para Emby, Jellyfin, Plex y cualquier plataforma de streaming personalizada. También puedes alojar tu propio servidor con Docker.",
|
||||
"FAQ_Q4": "¿Qué navegadores y plataformas son compatibles?",
|
||||
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave y todos los navegadores basados en Chromium. Instálalo directamente desde las tiendas oficiales sin configuraciones manuales.",
|
||||
"FAQ_Q5": "¿Todos mis amigos necesitan tener la extensión instalada?",
|
||||
"FAQ_A5": "Sí, cada participante necesita KoalaSync, pero no hace falta registrarse. Solo hay que hacer clic en el enlace, instalar la extensión si no se tiene y la unión a la sala es automática.",
|
||||
"FAQ_Q6": "¿Están seguros mis datos? ¿Y si no confío en el servidor oficial?",
|
||||
"FAQ_A6": "Totalmente seguro. El servidor relay de KoalaSync usa solo RAM — todos los datos de sesión se eliminan al salir de la sala. Sin base de datos, sin registros, sin análisis, sin cuentas. Si prefieres control total: aloja tu propio servidor relay privado con Docker en minutos. El código fuente es completamente abierto y auditable en GitHub.",
|
||||
"FAQ_Q7": "¿Puedo arreglar diálogos bajos y explosiones fuertes al ver videos en el navegador?",
|
||||
"FAQ_A7": "Sí. KoalaSync 2.2.0 incluye un compresor de audio integrado que reduce la diferencia entre las partes bajas y altas de tu video. Funciona localmente a través de la API Web Audio — ningún dato de audio sale de tu ordenador. Abre el popup, crea una sala, ve a Ajustes → Audio Processing, activa el compresor y elige un preset. Funciona en Netflix, Prime Video, Disney+, YouTube y cualquier sitio de video HTML5.",
|
||||
"FAQ_A6": "Totalmente seguros. El servidor de KoalaSync funciona solo en RAM: todos los datos de la sesión se borran permanentemente en cuanto sales de la sala. Sin registros ni analíticas. Si prefieres el control total, puedes auto-alojar tu propio servidor privado mediante Docker.",
|
||||
"FAQ_Q7": "¿Puedo arreglar los diálogos bajos y las explosiones fuertes al ver vídeos?",
|
||||
"FAQ_A7": "Sí. KoalaSync incluye un compresor de audio integrado que equilibra las partes bajas y altas del sonido. Funciona localmente mediante la Web Audio API: ningún dato de audio sale de tu equipo. Ve a Configuración → Procesamiento de audio y activa el compresor.",
|
||||
"HOWTO_TITLE": "Cómo sincronizar videos con amigos usando KoalaSync",
|
||||
"HOWTO_DESC": "Mira videos en perfecta sincronía con amigos en YouTube, Netflix, Twitch y más. Sin registro, sin seguimiento.",
|
||||
"HOWTO_STEP_1_NAME": "Instalar la extensión",
|
||||
"HOWTO_STEP_1_TEXT": "Añade KoalaSync a tu navegador desde Chrome Web Store o Firefox Add-ons. Funciona en cualquier sitio con un elemento de video.",
|
||||
"HOWTO_STEP_2_NAME": "Crear una sala",
|
||||
"HOWTO_STEP_2_TEXT": "Abre la extensión y haz clic en Crear nueva sala. El enlace de invitación se copia automáticamente al portapapeles.",
|
||||
"HOWTO_STEP_3_NAME": "Compartir y sincronizar",
|
||||
"HOWTO_STEP_3_TEXT": "Envía el enlace de invitación a tus amigos. Selecciona una pestaña de video y disfruta de reproducción sincronizada en tiempo real.",
|
||||
"HOWTO_DESC": "Mira cualquier video en sincronía con amigos en YouTube, Netflix, Twitch y más. Sin registros ni rastreo.",
|
||||
"HOWTO_STEP_1_NAME": "Instala la extensión",
|
||||
"HOWTO_STEP_1_TEXT": "Añade KoalaSync a tu navegador desde la Chrome Web Store o Firefox Add-ons. Funciona en cualquier web con reproductor de vídeo.",
|
||||
"HOWTO_STEP_2_NAME": "Crea una sala",
|
||||
"HOWTO_STEP_2_TEXT": "Abre la extensión y haz clic en 'Crear nueva sala'. El enlace de invitación se copiará automáticamente.",
|
||||
"HOWTO_STEP_3_NAME": "Comparte y sincroniza",
|
||||
"HOWTO_STEP_3_TEXT": "Envía el enlace a tus amigos. Selecciona la pestaña del video y disfrutad de la reproducción en tiempo real.",
|
||||
"FOOTER_MIT": "Código abierto bajo la Licencia MIT.",
|
||||
"FOOTER_RAM": "No se almacenan datos en nuestros servidores. Retransmisión solo en RAM.",
|
||||
"FOOTER_LEGAL": "Legal Notice",
|
||||
"FOOTER_PRIVACY": "Privacy Policy",
|
||||
"FOOTER_RAM": "No almacenamos datos. Retransmisión pura basada en RAM.",
|
||||
"FOOTER_LEGAL": "Aviso Legal",
|
||||
"FOOTER_PRIVACY": "Política de Privacidad",
|
||||
"MOCK_01": "Sala activa",
|
||||
"MOCK_02": "Servidor oficial",
|
||||
"MOCK_03": "Enlace de invitación",
|
||||
"MOCK_04": "Participantes",
|
||||
"MOCK_04": "Participantes en la sala",
|
||||
"MOCK_05": "TÚ",
|
||||
"MOCK_06": "Reproduciendo",
|
||||
"MOCK_07": "Compañero",
|
||||
"MOCK_08": "Salir",
|
||||
"MOCK_07": "Participante",
|
||||
"MOCK_08": "Salir de la sala",
|
||||
"MOCK_09": "Seleccionar video",
|
||||
"MOCK_10": "Control remoto",
|
||||
"MOCK_11": "Reproducir",
|
||||
"MOCK_12": "Pausa",
|
||||
"MOCK_13": "Saltar a otros",
|
||||
"MOCK_14": "Última actividad",
|
||||
"MOCK_15": "Esperando a 1 compañero (KoalaPC)...",
|
||||
"MOCK_16": "Saltar y reproducir",
|
||||
"MOCK_17": "Tu nombre",
|
||||
"MOCK_18": "Ocultar pestañas",
|
||||
"MOCK_19": "Auto-sync siguiente episodio",
|
||||
"MOCK_20": "Auto-copiar enlace",
|
||||
"MOCK_21": "Notificaciones",
|
||||
"MOCK_22": "Solución de problemas",
|
||||
"MOCK_23": "Regenerar ID",
|
||||
"MOCK_24": "Usa esto si ves error de \"Identidad duplicada\".",
|
||||
"MOCK_25": "Estado de conexión",
|
||||
"MOCK_26": "Conectado",
|
||||
"MOCK_27": "Copiar logs",
|
||||
"MOCK_28": "Info de depuración de video",
|
||||
"MOCK_29": "Historial completo",
|
||||
"MOCK_30": "Registros (últimos 50)",
|
||||
"MOCK_31": "BORRAR",
|
||||
"MOCK_32": "CLEAR",
|
||||
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Reproducir",
|
||||
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pausar",
|
||||
"MOCK_13": "<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\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SINCRO",
|
||||
"MOCK_14": "Ir a la posición de otros",
|
||||
"MOCK_15": "Estado de la última actividad",
|
||||
"MOCK_16": "Esperando a 1 participante (KoalaPC)...",
|
||||
"MOCK_17": "Omitir y reproducir ya",
|
||||
"MOCK_18": "Tu nombre de usuario",
|
||||
"MOCK_19": "Ocultar pestañas sin video",
|
||||
"MOCK_20": "Sincro auto del siguiente episodio",
|
||||
"MOCK_21": "Auto-copiar enlace",
|
||||
"MOCK_22": "Notificaciones del navegador",
|
||||
"MOCK_23": "Resolución de problemas",
|
||||
"MOCK_24": "Regenerar ID de usuario",
|
||||
"MOCK_25": "Usa esto si ves errores de \"Identidad duplicada\".",
|
||||
"MOCK_26": "Estado de la conexión",
|
||||
"MOCK_27": "Conectado",
|
||||
"MOCK_28": "Copiar registros",
|
||||
"MOCK_29": "Información de depuración de video",
|
||||
"MOCK_30": "Historial completo",
|
||||
"MOCK_31": "Registros (Últimos 50)",
|
||||
"MOCK_32": "LIMPIAR",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync no está afiliado, respaldado ni asociado con Netflix, Disney+, Amazon, YouTube, Twitch ni ninguna otra plataforma de streaming. Todas las marcas comerciales son propiedad de sus respectivos dueños.",
|
||||
"FOOTER_SUPPORT": "Invítame a un café en Ko-Fi"
|
||||
"FOOTER_DISCLAIMER": "KoalaSync no está afiliado, respaldado ni asociado con Netflix, Disney+, Amazon, YouTube, Twitch ni ninguna otra plataforma de streaming. Todas las marcas comerciales pertenecen a sus respectivos dueños.",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"ALT_TELEPARTY_TITLE": "KoalaSync frente a Teleparty: ¿qué herramienta de visionado conjunto deberías usar?",
|
||||
"ALT_TELEPARTY_META_DESC": "Una comparativa práctica entre KoalaSync y Teleparty para ver contenido en grupo, analizando aspectos como el chat integrado, la compatibilidad móvil, la sincronización de vídeo en el navegador, los servidores multimedia autohospedados y el uso de código abierto.",
|
||||
"ALT_TELEPARTY_INTRO": "Tanto KoalaSync como Teleparty ayudan a resolver el mismo problema molesto: ver algo juntos sin tener que recurrir a la típica rutina de «3, 2, 1, ¡reproducir!». Simplemente adoptan enfoques diferentes.<br><br>Teleparty es la opción más lógica si buscas un chat integrado o si quieres ver contenido desde un dispositivo móvil. KoalaSync está más orientado a sesiones de visionado en navegadores de escritorio, donde ya te comunicas a través de Discord, Teamspeak u otra aplicación de voz y solo necesitas que la reproducción se mantenga sincronizada.<br><br>La razón principal por la que creé KoalaSync fue la flexibilidad. Está diseñado para funcionar con casi cualquier sitio web que utilice un reproductor de vídeo estándar de navegador, no solo con una lista fija de plataformas. Esto incluye configuraciones autohospedadas como Emby, Jellyfin y Plex, dominios locales o privados y muchas páginas de vídeo convencionales.",
|
||||
"ALT_TELEPARTY_WHEN_TELE_TITLE": "Cuándo puede seguir siendo útil Teleparty",
|
||||
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty puede seguir siendo la mejor opción si prefieres que el chat forme parte de la propia herramienta de visionado. Actualmente, KoalaSync no cuenta con chat integrado; parte de la premisa de que ya te estás comunicando por otro medio, como Discord, Teamspeak, WhatsApp, Signal o una llamada convencional.<br><br>La compatibilidad móvil es otro factor clave. Teleparty dispone de aplicación propia, mientras que KoalaSync, por ahora, solo funciona en navegadores de escritorio. Si quieres sincronizar Netflix en un teléfono o tableta, KoalaSync no es la herramienta adecuada en este momento.<br><br>Por tanto, si el chat y la compatibilidad móvil son tus prioridades, es posible que Teleparty se adapte mejor a tus necesidades.",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "Cuándo es mejor usar KoalaSync",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_BODY": "KoalaSync es una mejor opción si buscas una herramienta flexible para sincronizar la reproducción de vídeo en navegadores de escritorio, en lugar de algo limitado a una lista reducida de plataformas.<br><br>Funciona con casi cualquier sitio web que utilice el reproductor de vídeo estándar del navegador. Esto resulta especialmente útil para Emby, Jellyfin, Plex, servidores multimedia privados, dominios locales o sitios de vídeo más pequeños que las herramientas convencionales de visionado compartido suelen ignorar.<br><br>Además, es gratuito, de código abierto, no requiere crear cuentas y permite el autohospedaje si deseas gestionar tu propio servidor de retransmisión (relay). Lo creé porque buscaba una solución respetuosa con la privacidad que funcionara con mi propia configuración de Emby, y no otra plataforma cerrada limitada a unos pocos grandes servicios de streaming.",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "La diferencia principal",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty está concebido principalmente como una herramienta para ver contenido en grupo, con funciones de chat y compatibilidad móvil. KoalaSync, en cambio, funciona más bien como una capa flexible de sincronización de vídeo para navegadores.<br><br>Puede parecer una diferencia menor, pero cambia considerablemente el caso de uso. Si necesitas una aplicación completa para ver contenido en grupo que incluya chat integrado, Teleparty es la opción lógica. Si ya dispones de chat de voz y solo quieres mantener sincronizados los controles de vídeo entre distintos navegadores, KoalaSync es la solución más sencilla.",
|
||||
"ALT_TELEPARTY_LIMITS_TITLE": "Limitaciones reales",
|
||||
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync no hace milagros ni pretende ocultar sus limitaciones. Actualmente, solo funciona en navegadores de escritorio; no cuenta con chat integrado ni aplicación móvil.<br><br>La compatibilidad también depende del navegador y del reproductor de vídeo utilizado. Aunque KoalaSync debería funcionar con casi cualquier reproductor estándar de navegador, los sitios web pueden modificar sus reproductores, el comportamiento del DRM o las restricciones del navegador en cualquier momento.<br><br>KoalaSync no tiene ninguna relación con Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex ni ninguna otra marca mencionada aquí. Dichos nombres se utilizan únicamente para ilustrar casos de uso habituales.",
|
||||
"ALT_TELEPARTY_SUMMARY_TITLE": "Resumen",
|
||||
"ALT_TELEPARTY_SUMMARY_BODY": "Si buscas chat integrado o compatibilidad con dispositivos móviles, Teleparty podría ser la mejor opción. Si prefieres una solución gratuita y de código abierto para sincronizar navegadores de escritorio que funcione con casi cualquier reproductor de vídeo convencional —incluidos servidores multimedia de alojamiento propio como Emby, Jellyfin y Plex—, KoalaSync es probablemente la alternativa más adecuada.",
|
||||
"ALT_SCREEN_TITLE": "KoalaSync frente a compartir pantalla: ¿cuándo es mejor la sincronización de vídeo?",
|
||||
"ALT_SCREEN_META_DESC": "Una comparativa práctica entre KoalaSync y la función de compartir pantalla para noches de cine a distancia, analizando aspectos como el control de reproducción, la calidad de vídeo, la privacidad, los subtítulos y la visualización desde el navegador.",
|
||||
"ALT_SCREEN_INTRO": "Compartir pantalla funciona y, sinceramente, a veces es suficiente. Una persona comparte su pantalla, las demás la ven y listo.<br><br>Sin embargo, para sesiones de cine más largas o para ver episodios en grupo, puede resultar molesto. La calidad del vídeo depende de la velocidad de subida, la CPU y el navegador de quien comparte, así como de la compresión que apliquen aplicaciones como Discord, Teamspeak, Zoom u otras plataformas de videollamada. Además, solo una persona tiene el control real de la reproducción.",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_TITLE": "Cuándo basta con compartir pantalla",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_BODY": "Compartir pantalla es una buena opción si buscas una configuración lo más rápida posible. Si solo una persona tiene acceso al vídeo o si a todos les parece bien que una sola persona controle la reproducción, la pausa y el avance o retroceso, puede que sea la opción más sencilla.<br><br>También tiene sentido cuando la calidad no es una prioridad, cuando nadie necesita sus propios subtítulos o ajustes de audio, y cuando la persona que comparte se siente cómoda mostrando esa ventana del navegador o su pantalla.",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_TITLE": "Cuando compartir pantalla resulta molesto",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_BODY": "La mayor desventaja es que no todos ven el contenido en su propio reproductor. Una persona transmite su pantalla y el resto solo ve esa transmisión.<br><br>Esto puede implicar una peor calidad de video, audio comprimido, retraso en la reproducción y menos control para los demás. Además, los subtítulos y las pistas de audio serán los que haya elegido la persona que comparte.<br><br>También está el aspecto de la privacidad. Si cambias de ventana o de pestaña, los demás podrían ver brevemente las pestañas de tu navegador, tu correo electrónico, Reddit, chats u otra información privada. Puedes evitar esto usando una ventana independiente, pero es algo que debes tener en cuenta.",
|
||||
"ALT_SCREEN_WHY_CLEANER_TITLE": "Por qué KoalaSync puede resultar más limpio",
|
||||
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSync no transmite tu pantalla. Cada persona ve el video localmente en su propio navegador, y KoalaSync solo sincroniza las acciones de reproducción, como iniciar, pausar y avanzar o retroceder.<br><br>Esto significa que todos pueden usar la pantalla completa en su propio dispositivo, utilizar sus propios ajustes de subtítulos o audio (si el reproductor lo permite) y mantener el chat de voz separado en Discord, Teamspeak o cualquier otra aplicación de llamadas.<br><br>Para las noches de cine, esto puede resultar mucho más limpio que tener a una persona transmitiendo su pantalla. Es especialmente útil para configuraciones basadas en navegador como Emby, Jellyfin, Plex, archivos locales abiertos en el navegador, dominios privados u otros sitios web que utilicen reproductores de video estándar del navegador.",
|
||||
"ALT_SCREEN_LIMITS_TITLE": "Limitaciones reales",
|
||||
"ALT_SCREEN_LIMITS_BODY": "KoalaSync no sustituye a una llamada de voz. Si quieres hablar mientras ves el contenido, puedes seguir usando Discord, Teamspeak, Signal, WhatsApp o cualquier otra herramienta que utilices habitualmente.<br><br>Además, cada participante necesita acceder al vídeo o a la página web desde su propio dispositivo. KoalaSync no elude suscripciones, inicios de sesión, DRM, restricciones regionales ni las normas de las plataformas.<br><br>Actualmente solo funciona en navegadores de escritorio, no incluye funciones de chat o voz, y la compatibilidad depende siempre del navegador y del reproductor de vídeo.",
|
||||
"ALT_SCREEN_SUMMARY_TITLE": "Resumen",
|
||||
"ALT_SCREEN_SUMMARY_BODY": "Compartir pantalla es la opción más sencilla cuando buscas algo rápido y no te preocupa demasiado la calidad, la privacidad o el control individual. KoalaSync es una mejor alternativa cuando cada persona debe ver el contenido localmente en su propio navegador y se busca una experiencia que realmente se asemeje a verlo juntos.",
|
||||
"ALT_TRY_KOALASYNC": "Prueba KoalaSync"
|
||||
}
|
||||
|
||||
+36
-6
@@ -7,6 +7,7 @@
|
||||
"OG_LOCALE": "fr_FR",
|
||||
"META_TITLE": "KoalaSync | Synchronisez Netflix, YouTube et n'importe quelle vidéo avec vos amis",
|
||||
"META_DESCRIPTION": "Regardez Netflix, YouTube, Twitch et des vidéos HTML5 en synchro avec vos amis. Extension de navigateur gratuite et open-source. Aucune inscription requise.",
|
||||
"SCHEMA_APP_DESC": "Regardez Netflix, YouTube, Twitch et des vidéos HTML5 en synchro avec vos amis. Extension de navigateur gratuite et open-source. Aucune inscription requise.",
|
||||
"OG_TITLE": "KoalaSync | Synchronisez Netflix, Emby, Jellyfin et presque toutes les vidéos avec vos amis",
|
||||
"OG_DESCRIPTION": "Regardez Netflix, Emby, Jellyfin, YouTube, Twitch et presque n'importe quelle vidéo HTML5 en parfaite synchronisation. Extension de navigateur open-source et respectueuse de la vie privée pour Chrome et Firefox.",
|
||||
"TWITTER_TITLE": "KoalaSync | Synchronisez Netflix, Emby, Jellyfin et presque toutes les vidéos avec vos amis – Extension de navigateur",
|
||||
@@ -49,7 +50,7 @@
|
||||
"FEATURE_7_TITLE": "Égalisation du volume / Compresseur audio",
|
||||
"FEATURE_7_DESC": "Fini de jouer avec le bouton du volume. Le compresseur audio intégré booste les dialogues discrets et adoucit les explosions. Fonctionne sur Netflix, Prime Video, Disney+, YouTube et toute vidéo HTML5. Entièrement local — aucune donnée audio ne quitte votre ordinateur.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"FEATURE_8_DESC": "Tout le code source est publiquement auditable sur GitHub. Pas de boite noire, pas de blob proprietaire - transparence totale du serveur relais a l'extension de navigateur.",
|
||||
"COMP_TITLE": "KoalaSync vs Teleparty",
|
||||
"COMP_SUBTITLE": "Découvrez pourquoi un outil open-source, sans publicité et respectueux de la vie privée est le meilleur choix pour regarder ensemble.",
|
||||
"COMP_COL_FEATURE": "Fonctionnalité",
|
||||
@@ -75,7 +76,7 @@
|
||||
"COMP_FEAT_5_TELE": "Sites pris en charge uniquement",
|
||||
"COMP_FEAT_6_NAME": "Localisation / Traduction",
|
||||
"COMP_FEAT_6_DESC": "Langues d'interface disponibles et support de traduction.",
|
||||
"COMP_FEAT_6_KOALA": "13 langues (et plus à venir)",
|
||||
"COMP_FEAT_6_KOALA": "15 langues (et plus à venir)",
|
||||
"COMP_FEAT_6_TELE": "Anglais uniquement",
|
||||
"COMP_FEAT_7_NAME": "Compresseur Audio / Égalisation du Volume",
|
||||
"COMP_FEAT_7_DESC": "Normalisation audio intégrée pour équilibrer les dialogues silencieux et les effets sonores",
|
||||
@@ -116,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Tout ce que vous devez savoir pour regarder des vidéos en parfaite synchronisation.",
|
||||
"FAQ_Q1": "Puis-je regarder Netflix, Emby ou Jellyfin en synchronisation avec des amis ?",
|
||||
"FAQ_A1": "Oui ! KoalaSync fonctionne avec Netflix, YouTube, Twitch et tout site avec un lecteur vidéo HTML5 — y compris Emby, Jellyfin et les serveurs multimédia auto-hébergés. Installez l'extension, créez un salon, partagez le lien et regardez en parfaite synchronisation.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync diffuse-t-il la vidéo à mes amis ?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Non. KoalaSync ne diffuse, n’héberge, ne partage et ne contourne aucun accès aux contenus vidéo. Chaque personne regarde la vidéo localement dans son propre navigateur et doit disposer de son propre accès au site ou au service de streaming, par exemple son propre compte Netflix. KoalaSync synchronise uniquement les actions de lecture comme lecture, pause et recherche dans la vidéo.",
|
||||
"FAQ_Q2": "KoalaSync est-il une alternative gratuite à Teleparty ?",
|
||||
"FAQ_A2": "100 % gratuit et open source sous licence MIT. Aucun niveau premium, aucune fonctionnalité verrouillée, aucune publicité. Contrairement à Teleparty, le serveur relay de KoalaSync fonctionne uniquement en RAM — sans collecte de données ni comptes utilisateur.",
|
||||
"FAQ_Q3": "KoalaSync fonctionne-t-il avec Emby, Jellyfin et les serveurs multimédia auto-hébergés ?",
|
||||
@@ -150,9 +153,9 @@
|
||||
"MOCK_08": "Quitter",
|
||||
"MOCK_09": "Choisir la vidéo",
|
||||
"MOCK_10": "Télécommande",
|
||||
"MOCK_11": "Lire",
|
||||
"MOCK_12": "Pause",
|
||||
"MOCK_13": "Sauter vers les autres",
|
||||
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Lire",
|
||||
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pause",
|
||||
"MOCK_13": "<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\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SYNC",
|
||||
"MOCK_14": "Dernière activité",
|
||||
"MOCK_15": "En attente de 1 participant (KoalaPC)...",
|
||||
"MOCK_16": "Passer & lire",
|
||||
@@ -175,5 +178,32 @@
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync n'est pas affilié à Netflix, Disney+, Amazon, YouTube, Twitch ou toute autre plateforme de streaming. Toutes les marques déposées appartiennent à leurs détenteurs respectifs.",
|
||||
"FOOTER_SUPPORT": "Offre-moi un café sur Ko-Fi"
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"ALT_TELEPARTY_TITLE": "KoalaSync vs Teleparty : quel outil de visionnage en groupe choisir ?",
|
||||
"ALT_TELEPARTY_META_DESC": "Une comparaison pratique entre KoalaSync et Teleparty pour le visionnage en groupe : chat intégré, compatibilité mobile, synchronisation vidéo via navigateur, serveurs multimédias auto-hébergés et nature open-source.",
|
||||
"ALT_TELEPARTY_INTRO": "KoalaSync et Teleparty répondent tous deux au même problème agaçant : regarder quelque chose ensemble sans avoir à passer par le rituel du « 3, 2, 1, lecture ». Ils adoptent simplement des approches différentes.<br><br>Teleparty est la solution idéale si vous souhaitez un chat intégré ou si vous voulez regarder du contenu sur mobile. KoalaSync est davantage conçu pour le visionnage sur navigateur (PC/Mac) lorsque vous communiquez déjà via Discord, Teamspeak ou un autre service d'appel vocal, et que vous voulez simplement que la lecture reste synchronisée.<br><br>La raison principale pour laquelle j'ai créé KoalaSync est la flexibilité. L'outil est conçu pour fonctionner avec presque tous les sites web utilisant un lecteur vidéo standard, et non avec une liste fixe de plateformes. Cela inclut les installations auto-hébergées comme Emby, Jellyfin et Plex, les domaines locaux ou privés, ainsi que de nombreuses pages vidéo classiques sur navigateur.",
|
||||
"ALT_TELEPARTY_WHEN_TELE_TITLE": "Quand Teleparty reste une bonne option",
|
||||
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty peut s'avérer être le meilleur choix si vous souhaitez que le chat soit directement intégré à l'outil de visionnage. KoalaSync ne propose pas de chat intégré pour le moment ; il part du principe que vous communiquez déjà par ailleurs, via Discord, Teamspeak, WhatsApp, Signal ou un appel classique.<br><br>L'aspect mobile constitue une autre raison majeure. Teleparty dispose d'une application, alors que KoalaSync ne fonctionne actuellement que sur les navigateurs de bureau. Si vous souhaitez synchroniser Netflix sur un téléphone ou une tablette, KoalaSync n'est pas l'outil adapté pour l'instant.<br><br>Ainsi, si le chat et la compatibilité mobile sont vos priorités, Teleparty pourrait tout simplement mieux convenir à votre configuration.",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "Quand choisir KoalaSync",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_BODY": "KoalaSync est plus adapté si vous recherchez un outil flexible de synchronisation vidéo pour navigateur de bureau, plutôt qu'une solution limitée à une courte liste de plateformes.<br><br>Il fonctionne avec presque tous les sites web utilisant un lecteur vidéo standard de navigateur. C'est particulièrement utile pour Emby, Jellyfin, Plex, les serveurs multimédias privés, les domaines locaux ou les sites vidéo plus modestes que les outils classiques de visionnage en groupe ignorent généralement.<br><br>De plus, c'est un outil gratuit et open source qui ne nécessite pas de compte et peut être auto-hébergé si vous souhaitez gérer votre propre relais. Je l'ai créé parce que je voulais une solution respectueuse de la vie privée compatible avec ma propre installation Emby, et non une plateforme fermée supplémentaire réservée à quelques grands sites de streaming.",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "La différence principale",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty est avant tout un outil de visionnage en groupe incluant une messagerie et une prise en charge mobile. KoalaSync se présente davantage comme une couche de synchronisation flexible pour la vidéo sur navigateur.<br><br>Cela peut sembler être un détail, mais cela change considérablement l'usage. Si vous avez besoin d'une application complète de visionnage en groupe avec messagerie intégrée, Teleparty est tout indiqué. Si vous disposez déjà d'un système de discussion vocale et que vous souhaitez simplement synchroniser les commandes vidéo entre différents navigateurs, KoalaSync est la solution la plus simple.",
|
||||
"ALT_TELEPARTY_LIMITS_TITLE": "Limites en toute transparence",
|
||||
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync n'est pas magique et ne cherche pas à dissimuler ses limites. Actuellement, il ne fonctionne que sur les navigateurs de bureau. Il ne dispose ni de messagerie intégrée ni d'application mobile.<br><br>La compatibilité dépend également du navigateur et du lecteur vidéo. KoalaSync devrait fonctionner avec presque tous les lecteurs vidéo standard, mais les sites web peuvent modifier leurs lecteurs, la gestion des DRM ou les restrictions du navigateur à tout moment.<br><br>KoalaSync n'est affilié ni à Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex, ni à aucune autre marque mentionnée ici. Ces noms sont utilisés uniquement pour illustrer des cas d'utilisation courants.",
|
||||
"ALT_TELEPARTY_SUMMARY_TITLE": "Résumé",
|
||||
"ALT_TELEPARTY_SUMMARY_BODY": "Si vous recherchez une fonction de chat intégrée ou une prise en charge mobile, Teleparty pourrait être la meilleure option. Si vous préférez une solution gratuite et open source de synchronisation via navigateur (sur ordinateur) compatible avec presque tous les lecteurs vidéo classiques — y compris les serveurs multimédias auto-hébergés comme Emby, Jellyfin et Plex —, alors KoalaSync est probablement le choix idéal.",
|
||||
"ALT_SCREEN_TITLE": "KoalaSync vs partage d'écran : quand la synchronisation vidéo est-elle préférable ?",
|
||||
"ALT_SCREEN_META_DESC": "Une comparaison pratique entre KoalaSync et le partage d'écran pour les soirées cinéma à distance : contrôle de la lecture, qualité vidéo, confidentialité, sous-titres et visionnage via navigateur.",
|
||||
"ALT_SCREEN_INTRO": "Le partage d'écran fonctionne et, honnêtement, cela suffit parfois. Une personne partage son écran, les autres regardent, et le tour est joué.<br><br>Cependant, pour des soirées cinéma plus longues ou pour regarder des épisodes ensemble, cela peut devenir contraignant. La qualité vidéo dépend de la vitesse d'envoi (upload), du processeur et du navigateur de la personne qui partage, ainsi que de la compression appliquée par Discord, Teamspeak, Zoom ou toute autre application d'appel. De plus, une seule personne contrôle réellement la lecture.",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_TITLE": "Quand le partage d'écran suffit",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_BODY": "Le partage d'écran est une bonne solution lorsque vous souhaitez une mise en place ultra-rapide. Si une seule personne a accès à la vidéo ou si tout le monde accepte qu'une seule personne contrôle la lecture, la pause et la navigation dans la vidéo, c'est peut-être l'option la plus simple.<br><br>C'est aussi une solution pertinente lorsque la qualité n'est pas une priorité, que personne n'a besoin de paramètres de sous-titres ou audio personnalisés, et que la personne qui partage l'écran est à l'aise à l'idée de montrer sa fenêtre de navigateur ou son écran.",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_TITLE": "Les inconvénients du partage d'écran",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_BODY": "Le principal inconvénient est que les participants ne regardent pas la vidéo via leur propre lecteur. Une personne diffuse son écran, et tous les autres ne voient que ce flux.<br><br>Cela peut entraîner une qualité vidéo moindre, une compression audio, un décalage de lecture et une perte de contrôle pour les autres spectateurs. De plus, les sous-titres et les pistes audio sont imposés par la personne qui partage.<br><br>Il y a aussi la question de la confidentialité. Si vous changez de fenêtre ou d'onglet, les autres peuvent brièvement voir vos onglets, vos e-mails, Reddit, vos discussions ou d'autres éléments privés. Vous pouvez contourner ce problème en utilisant une fenêtre séparée, mais c'est un aspect à prendre en compte.",
|
||||
"ALT_SCREEN_WHY_CLEANER_TITLE": "Pourquoi KoalaSync offre une expérience plus fluide",
|
||||
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSync ne diffuse pas votre écran. Chacun regarde la vidéo localement dans son propre navigateur, et KoalaSync se contente de synchroniser les actions de lecture telles que le lancement, la pause et la navigation dans la vidéo.<br><br>Ainsi, chacun peut passer en plein écran sur son appareil, utiliser ses propres paramètres de sous-titres ou audio (si le lecteur le permet) et gérer la discussion vocale séparément via Discord, Teamspeak ou un autre service d'appel.<br><br>Pour les soirées cinéma, cette approche est souvent plus agréable que la diffusion d'écran par une seule personne. C'est particulièrement utile pour les solutions basées sur un navigateur comme Emby, Jellyfin, Plex, les fichiers locaux ouverts dans le navigateur, les domaines privés ou tout autre site web utilisant des lecteurs vidéo standard.",
|
||||
"ALT_SCREEN_LIMITS_TITLE": "Limites à connaître",
|
||||
"ALT_SCREEN_LIMITS_BODY": "KoalaSync ne remplace pas un appel vocal. Si vous souhaitez discuter tout en regardant, vous continuez à utiliser Discord, Teamspeak, Signal, WhatsApp ou tout autre outil habituel.<br><br>Chacun doit également pouvoir accéder à la vidéo ou à la page depuis son propre appareil. KoalaSync ne contourne ni les abonnements, ni les identifiants, ni les DRM, ni les restrictions géographiques ou les règles des plateformes.<br><br>L'outil fonctionne actuellement uniquement sur les navigateurs de bureau, ne dispose pas de fonction de chat ou de voix intégrée, et la compatibilité dépend toujours du navigateur et du lecteur vidéo utilisé.",
|
||||
"ALT_SCREEN_SUMMARY_TITLE": "Résumé",
|
||||
"ALT_SCREEN_SUMMARY_BODY": "Le partage d'écran est la solution la plus simple si vous cherchez une méthode rapide sans accorder une grande importance à la qualité, à la confidentialité ou au contrôle individuel. KoalaSync est plus adapté lorsque chaque participant doit visionner le contenu localement dans son propre navigateur, pour une expérience se rapprochant davantage d'un véritable visionnage en commun.",
|
||||
"ALT_TRY_KOALASYNC": "Essayer KoalaSync"
|
||||
}
|
||||
|
||||
+165
-135
@@ -5,175 +5,205 @@
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "it_IT",
|
||||
"META_TITLE": "KoalaSync | Sincronizza Netflix, YouTube e qualsiasi video con gli amici",
|
||||
"META_DESCRIPTION": "Guarda Netflix, YouTube, Twitch e qualsiasi video HTML5 in perfetta sincronia con gli amici. Estensione del browser gratuita e open source per Chrome e Firefox. Nessuna registrazione richiesta.",
|
||||
"OG_TITLE": "KoalaSync | Sincronizza Netflix, Emby, Jellyfin e quasi tutti i video con gli amici",
|
||||
"OG_DESCRIPTION": "Guarda Netflix, Emby, Jellyfin, YouTube, Twitch e quasi tutti i video HTML5 in perfetta sincronia. Estensione del browser open source e attenta alla privacy per Chrome e Firefox.",
|
||||
"TWITTER_TITLE": "KoalaSync | Sincronizza Netflix, Emby, Jellyfin e quasi tutti i video con gli amici – Estensione Browser",
|
||||
"TWITTER_DESCRIPTION": "Guarda Netflix, Emby, Jellyfin, YouTube, Twitch e quasi tutti i video HTML5 in perfetta sincronia con gli amici. Estensione del browser open source e attenta alla privacy per Chrome e Firefox.",
|
||||
"META_TITLE": "KoalaSync | Sincronizza Netflix, YouTube e qualsiasi video con i tuoi amici",
|
||||
"META_DESCRIPTION": "Guarda Netflix, YouTube, Twitch e qualsiasi video HTML5 in perfetta sincronia. Estensione gratuita, open source e senza registrazione.",
|
||||
"SCHEMA_APP_DESC": "Guarda Netflix, YouTube, Twitch e qualsiasi video HTML5 in perfetta sincronia. Estensione gratuita, open source e senza registrazione.",
|
||||
"OG_TITLE": "KoalaSync | Sincronizza Netflix, Emby, Jellyfin e video web con i tuoi amici",
|
||||
"OG_DESCRIPTION": "Guarda video in perfetta sincronia con i tuoi amici. Estensione open source per Chrome e Firefox incentrata sulla privacy.",
|
||||
"TWITTER_TITLE": "KoalaSync | Sincronizza video in streaming con i tuoi amici",
|
||||
"TWITTER_DESCRIPTION": "Guarda video insieme ai tuoi amici in perfetta sincronia. Open source e rispettoso della privacy.",
|
||||
"NAV_FEATURES": "Funzionalità",
|
||||
"NAV_HOW_IT_WORKS": "Come Funziona",
|
||||
"HERO_TITLE": "Guarda Insieme.<span class='hero-line2'>Sincronizza Perfettamente.</span>",
|
||||
"HERO_SUBTITLE": "La tua serata cinema a distanza senza lag. Nessuna registrazione, nessun dato raccolto. Condividi un link e guarda insieme.",
|
||||
"HERO_MASCOT_ALT": "Un simpatico koala in piedi che guarda in basso verso i pulsanti di download",
|
||||
"HERO_TITLE": "Guardate Insieme.<span class='hero-line2'>Sincronia Perfetta.</span>",
|
||||
"HERO_SUBTITLE": "La tua serata cinema a distanza senza ritardi. Nessun account, nessun dato raccolto. Condividi un link e guardate insieme.",
|
||||
"HERO_MASCOT_ALT": "Un simpatico koala che guarda i pulsanti di download",
|
||||
"ADD_TO_CHROME": "Aggiungi a Chrome",
|
||||
"ADD_TO_FIREFOX": "Aggiungi a Firefox",
|
||||
"COMPAT_HEADING": "Funziona sulle tue piattaforme preferite",
|
||||
"COMPAT_MORE": "e molti altri",
|
||||
"COMPAT_TOOLTIP": "Funziona su quasi tutti i siti con un elemento video",
|
||||
"USE_CASES_TITLE": "Perfetto per Ogni Occasione",
|
||||
"COMPAT_HEADING": "Compatibile con le tue piattaforme preferite",
|
||||
"COMPAT_MORE": "e molte altre",
|
||||
"COMPAT_TOOLTIP": "Funziona su quasi tutti i siti con video HTML5",
|
||||
"USE_CASES_TITLE": "Perfetto per ogni occasione",
|
||||
"USE_CASES_SUBTITLE": "Vicini o lontani, KoalaSync unisce le persone attorno ai loro video preferiti.",
|
||||
"USE_CASE_1_ALT": "Due simpatici koala seduti insieme che condividono un secchio di popcorn",
|
||||
"USE_CASE_1_ALT": "Due koala che mangiano popcorn insieme",
|
||||
"USE_CASE_1_TITLE": "Serata Cinema con gli Amici",
|
||||
"USE_CASE_1_DESC": "Sincronizza i tuoi film in tempo reale e parla via Discord, Zoom o la tua app di chiamata vocale preferita. Sembrerà di essere nella stessa stanza.",
|
||||
"USE_CASE_2_ALT": "Un professore koala che presenta su uno schermo a due studenti koala a distanza",
|
||||
"USE_CASE_1_DESC": "Sincronizza i tuoi film in tempo reale e parla su Discord o Zoom. Sembrerà di essere nella stessa stanza.",
|
||||
"USE_CASE_2_ALT": "Un professore koala che insegna a distanza",
|
||||
"USE_CASE_2_TITLE": "Apprendimento a Distanza",
|
||||
"USE_CASE_2_DESC": "Analizza tutorial online, lezioni o stream di formazione per sviluppatori insieme a compagni di classe o colleghi. Fai pausa e discuti i passaggi complessi in perfetta armonia.",
|
||||
"USE_CASE_3_ALT": "Una simpatica coppia di koala che guarda insieme a distanza; uno è seduto al PC e l'altro è a letto con un laptop, con cuori che volano tra loro",
|
||||
"USE_CASE_2_DESC": "Analizza tutorial o lezioni insieme a colleghi e compagni. Metti in pausa e discuti i passaggi più complessi in armonia.",
|
||||
"USE_CASE_3_ALT": "Una coppia di koala che guarda video a distanza con dei cuori",
|
||||
"USE_CASE_3_TITLE": "Relazioni a Distanza",
|
||||
"USE_CASE_3_DESC": "Supera le distanze e goditi le serate romantiche. Vivi ogni colpo di scena, ridi alle stesse battute e condividi momenti emozionanti nello stesso identico millisecondo.",
|
||||
"USE_CASE_3_DESC": "Accorcia le distanze e goditi serate romantiche. Vivi ogni emozione e ridi alle stesse battute nello stesso identico istante.",
|
||||
"WHY_TITLE": "Perché KoalaSync?",
|
||||
"WHY_SUBTITLE": "Progettato per una sincronizzazione affidabile, privacy al primo posto e configurazione semplice.",
|
||||
"FEATURE_1_TITLE": "Controllo Totale / Sincronizzazione Istantanea",
|
||||
"FEATURE_1_DESC": "Uno mette in pausa, tutti mettono in pausa. Uno cerca, tutti lo seguono. Il nostro protocollo di sincronizzazione a due fasi coordina la riproduzione in tempo reale tra tutti i partecipanti.",
|
||||
"FEATURE_2_TITLE": "Binge-Watching Infinito",
|
||||
"FEATURE_2_DESC": "Riproduzione automatica sincronizzata. KoalaSync rileva automaticamente i cambi di episodio e blocca la riproduzione finché tutti i partecipanti non hanno caricato il video successivo.",
|
||||
"FEATURE_3_TITLE": "Zero Account / Massima Privacy",
|
||||
"FEATURE_3_DESC": "Nessuna registrazione, nessun tracciamento, nessuna traccia persistente. Il server funziona interamente nella RAM effimera e cancella la tua stanza quando te ne vai.",
|
||||
"WHY_SUBTITLE": "Sincronizzazione affidabile, massima privacy e configurazione immediata.",
|
||||
"FEATURE_1_TITLE": "Controllo Totale / Sincro Istantanea",
|
||||
"FEATURE_1_DESC": "Se uno mette in pausa, tutti si fermano. Se uno salta in avanti, gli altri lo seguono. Tutto in tempo reale.",
|
||||
"FEATURE_2_TITLE": "Maratone senza interruzioni",
|
||||
"FEATURE_2_DESC": "Riproduzione automatica sincronizzata. KoalaSync attende che tutti abbiano caricato l'episodio successivo prima di farlo partire.",
|
||||
"FEATURE_3_TITLE": "Senza Account / Privacy Totale",
|
||||
"FEATURE_3_DESC": "Niente registrazioni o tracciamenti. Il server non salva nulla e cancella la tua stanza appena te ne vai.",
|
||||
"FEATURE_4_TITLE": "Supporto HTML5 Universale",
|
||||
"FEATURE_4_DESC": "Funziona su YouTube, Twitch, Netflix, Jellyfin, Emby e quasi tutte le pagine web standard contenenti un elemento video HTML5. Compatibile anche con server personalizzati. Nessun dato video viene mai inviato ai nostri server — solo metadati di sincronizzazione.",
|
||||
"FEATURE_5_TITLE": "Ospitabile Autonomamente e Pronto per Docker",
|
||||
"FEATURE_5_DESC": "I nostri server ufficiali sono gratuiti e veloci, ma puoi gestirne uno tuo se preferisci. Avvia il tuo server di inoltro privato in pochi secondi tramite Docker.",
|
||||
"FEATURE_6_TITLE": "Inviti Istantanei / Accesso con 1 Click",
|
||||
"FEATURE_6_DESC": "Nessun indirizzo IP o password da scambiare. Condividi un link di invito generato con i tuoi amici per farli entrare nella stanza automaticamente con un solo clic.",
|
||||
"FEATURE_7_TITLE": "Equalizzazione Volume / Compressore Audio",
|
||||
"FEATURE_7_DESC": "Basta regolare continuamente il volume. Il compressore audio integrato potenzia i dialoghi sommessi e attenua le esplosioni forti. Funziona su Netflix, Prime Video, Disney+, YouTube e qualsiasi video HTML5. Completamente locale — nessun dato audio lascia il tuo computer.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"COMP_TITLE": "KoalaSync vs. Teleparty",
|
||||
"COMP_SUBTITLE": "Scopri perché l'open source, senza pubblicità e attento alla privacy è il modo migliore per guardare i video insieme.",
|
||||
"COMP_COL_FEATURE": "Funzionalità",
|
||||
"COMP_FEAT_1_NAME": "Costi / Paywall",
|
||||
"COMP_FEAT_1_DESC": "Abbonamenti premium, costi nascosti o funzionalità bloccate.",
|
||||
"COMP_FEAT_1_KOALA": "100% Gratuito",
|
||||
"COMP_FEAT_1_TELE": "Piani a Pagamento / Blocchi Premium",
|
||||
"COMP_FEAT_2_NAME": "Licenza / Codice Controllabile",
|
||||
"COMP_FEAT_2_DESC": "Indica se il codice sorgente è aperto e liberamente controllabile.",
|
||||
"FEATURE_4_DESC": "Funziona su YouTube, Twitch, Netflix, Jellyfin, Emby e quasi ogni pagina web. Nessun dato video viene inviato ai nostri server.",
|
||||
"FEATURE_5_TITLE": "Ospitabile in Proprio (Docker)",
|
||||
"FEATURE_5_DESC": "I nostri server sono veloci, ma puoi gestire il tuo server privato in pochi secondi tramite Docker.",
|
||||
"FEATURE_6_TITLE": "Inviti Rapidi / Un clic per entrare",
|
||||
"FEATURE_6_DESC": "Niente password complicate. Condividi il link generato e i tuoi amici entreranno automaticamente con un solo clic.",
|
||||
"FEATURE_7_TITLE": "Volume Bilanciato / Compressore Audio",
|
||||
"FEATURE_7_DESC": "Basta regolare il volume ogni minuto. Il compressore integrato esalta le voci e attenua i rumori forti. Tutto in locale.",
|
||||
"FEATURE_8_TITLE": "Open Source (Licenza MIT)",
|
||||
"FEATURE_8_DESC": "Tutto il codice è pubblico su GitHub. Trasparenza totale, senza scatole nere o software proprietario.",
|
||||
"COMP_TITLE": "KoalaSync vs Teleparty",
|
||||
"COMP_SUBTITLE": "Scopri perché una soluzione open source e senza pubblicità è la scelta migliore per guardare video insieme.",
|
||||
"COMP_COL_FEATURE": "Caratteristica",
|
||||
"COMP_FEAT_1_NAME": "Costo / Paywall",
|
||||
"COMP_FEAT_1_DESC": "Abbonamenti premium o funzioni a pagamento.",
|
||||
"COMP_FEAT_1_KOALA": "100% Gratis",
|
||||
"COMP_FEAT_1_TELE": "Livelli a pagamento / Blocchi Premium",
|
||||
"COMP_FEAT_2_NAME": "Codice Trasparente",
|
||||
"COMP_FEAT_2_DESC": "Indica se il codice sorgente è aperto e verificabile da chiunque.",
|
||||
"COMP_FEAT_2_KOALA": "Open Source (MIT)",
|
||||
"COMP_FEAT_2_TELE": "Proprietario",
|
||||
"COMP_FEAT_3_NAME": "Ospitare in Proprio",
|
||||
"COMP_FEAT_3_DESC": "Possibilità di distribuire il proprio server di inoltro privato.",
|
||||
"COMP_FEAT_3_KOALA": "Sì (Pronto per Docker)",
|
||||
"COMP_FEAT_3_NAME": "Self-Hosting",
|
||||
"COMP_FEAT_3_DESC": "Possibilità di avviare il proprio server privato.",
|
||||
"COMP_FEAT_3_KOALA": "Sì (Docker)",
|
||||
"COMP_FEAT_3_TELE": "No",
|
||||
"COMP_FEAT_4_NAME": "Privacy e Tracciamento",
|
||||
"COMP_FEAT_4_DESC": "Requisiti di registrazione, cookie di tracciamento e analisi.",
|
||||
"COMP_FEAT_4_KOALA": "Nessuna Persistenza (Solo RAM)",
|
||||
"COMP_FEAT_4_NAME": "Privacy",
|
||||
"COMP_FEAT_4_DESC": "Registrazione obbligatoria e cookie di tracciamento.",
|
||||
"COMP_FEAT_4_KOALA": "Nessun dato salvato",
|
||||
"COMP_FEAT_4_TELE": "Google Analytics e Cookie",
|
||||
"COMP_FEAT_5_NAME": "Compatibilità dei Siti",
|
||||
"COMP_FEAT_5_DESC": "Siti web supportati e compatibilità dei player.",
|
||||
"COMP_FEAT_5_KOALA": "Quasi tutti i video HTML5",
|
||||
"COMP_FEAT_5_TELE": "Solo siti supportati",
|
||||
"COMP_FEAT_6_NAME": "Localizzazione / Traduzione",
|
||||
"COMP_FEAT_6_DESC": "Lingue dell'interfaccia disponibili e supporto alle traduzioni.",
|
||||
"COMP_FEAT_6_KOALA": "13 Lingue (in crescita)",
|
||||
"COMP_FEAT_5_NAME": "Compatibilità Siti",
|
||||
"COMP_FEAT_5_DESC": "Piattaforme supportate.",
|
||||
"COMP_FEAT_5_KOALA": "Quasi ogni video HTML5",
|
||||
"COMP_FEAT_5_TELE": "Solo siti selezionati",
|
||||
"COMP_FEAT_6_NAME": "Traduzione",
|
||||
"COMP_FEAT_6_DESC": "Lingue disponibili per l'interfaccia.",
|
||||
"COMP_FEAT_6_KOALA": "15 Lingue (in crescita)",
|
||||
"COMP_FEAT_6_TELE": "Solo Inglese",
|
||||
"COMP_FEAT_7_NAME": "Compressore Audio / Livellamento Volume",
|
||||
"COMP_FEAT_7_DESC": "Normalizzazione audio integrata per bilanciare dialoghi bassi ed effetti forti",
|
||||
"COMP_FEAT_7_KOALA": "Sì (4 preset, controllo dry/wet)",
|
||||
"COMP_FEAT_7_NAME": "Compressore Audio",
|
||||
"COMP_FEAT_7_DESC": "Bilanciamento automatico del suono per voci più chiare.",
|
||||
"COMP_FEAT_7_KOALA": "Sì (4 impostazioni)",
|
||||
"COMP_FEAT_7_TELE": "No",
|
||||
"COMP_FOOTNOTE_1": "Stato del confronto: Maggio 2026.",
|
||||
"COMP_FOOTNOTE_2": "Dettagli sui prezzi di Teleparty Premium e reti supportate:",
|
||||
"COMP_FOOTNOTE_3": "Informativa sulla privacy di Teleparty e raccolta dei dati di tracciamento:",
|
||||
"COMP_FOOTNOTE_4": "Funziona sui siti web che consentono l'iniezione di script nei tag video HTML5 standard. I siti web con Content Security Policy (CSP) molto rigide, protezioni DRM o wrapper del player fortemente offuscati (come shadow DOM complessi) potrebbero limitare il controllo o l'iniezione.",
|
||||
"STEPS_TITLE": "Guida Introduttiva",
|
||||
"COMP_FOOTNOTE_1": "Confronto aggiornato a: Maggio 2026.",
|
||||
"COMP_FOOTNOTE_2": "Dettagli sui prezzi premium di Teleparty:",
|
||||
"COMP_FOOTNOTE_3": "Politiche sulla privacy di Teleparty:",
|
||||
"COMP_FOOTNOTE_4": "Funziona su siti che permettono l'uso di script nei tag video HTML5. Alcuni siti con protezioni DRM molto rigide potrebbero limitare il controllo automatico.",
|
||||
"STEPS_TITLE": "Inizia ora",
|
||||
"STEP_1_TITLE": "Installa l'Estensione",
|
||||
"STEP_1_DESC": "Aggiungi KoalaSync al tuo browser dal Chrome Web Store, da Firefox Add-ons, o scarica l'ultimo ZIP per sviluppatori da GitHub.",
|
||||
"STEP_1_ILLUS_DESC": "Sincronizzatore video attento alla privacy",
|
||||
"STEP_1_DESC": "Aggiungi KoalaSync al tuo browser dal Chrome Web Store o dai componenti aggiuntivi di Firefox.",
|
||||
"STEP_1_ILLUS_DESC": "Sincronizzatore video privato",
|
||||
"STEP_1_ILLUS_DL_CHROME": "Scarica per Chrome",
|
||||
"STEP_1_ILLUS_DL_FIREFOX": "Scarica per Firefox",
|
||||
"STEP_1_ILLUS_ACTIVE": "Estensione Attiva",
|
||||
"STEP_1_ILLUS_READY": "Pronti a guardare insieme!",
|
||||
"STEP_1_ILLUS_READY": "Pronti a guardare!",
|
||||
"STEP_2_TITLE": "Crea una Stanza",
|
||||
"STEP_2_DESC": "Apri il popup dell'estensione e fai clic su '+ Crea Nuova Stanza'. KoalaSync genererà automaticamente un ID Stanza e una Password sicuri, entrerà nella stanza e copierà il link di invito negli appunti.",
|
||||
"STEP_2_DESC": "Apri l'estensione e clicca su '+ Crea Nuova Stanza'. Verrà generato un link di invito automaticamente.",
|
||||
"STEP_2_ILLUS_ROOM": "Stanza",
|
||||
"STEP_2_ILLUS_SYNC": "Sincronizza",
|
||||
"STEP_2_ILLUS_SETTINGS": "Impostazioni",
|
||||
"STEP_2_ILLUS_SYNC": "Sincro",
|
||||
"STEP_2_ILLUS_SETTINGS": "Ajustes",
|
||||
"STEP_2_ILLUS_CREATE": "+ Crea Nuova Stanza",
|
||||
"STEP_2_ILLUS_MANUAL": "Connessione Manuale / Avanzata",
|
||||
"STEP_2_ILLUS_COPIED": "Link di invito copiato!",
|
||||
"STEP_3_TITLE": "Condividi e Sincronizza",
|
||||
"STEP_3_DESC": "Invia il link di invito ai tuoi amici. Una volta entrati, seleziona la scheda del tuo video e goditi la riproduzione sincronizzata.",
|
||||
"STEP_3_ILLUS_IN_SYNC": "IN SINCRONIA",
|
||||
"SELF_TITLE": "Per chi Ospita Autonomamente",
|
||||
"SELF_SUBTITLE": "Non ti fidi del nostro server? Mantieni la piena sovranità sui tuoi dati. Distribuisci il tuo server di inoltro privato in pochi minuti.",
|
||||
"SELF_MASCOT_ALT": "Un simpatico koala seduto al laptop che distribuisce un contenitore Docker per l'hosting autonomo",
|
||||
"STEP_2_ILLUS_MANUAL": "Manuale / Avanzato",
|
||||
"STEP_2_ILLUS_COPIED": "Link copiato!",
|
||||
"STEP_3_TITLE": "Condividi e Guarda",
|
||||
"STEP_3_DESC": "Invia il link ai tuoi amici. Una volta connessi, scegli il video e goditi la riproduzione sincronizzata.",
|
||||
"STEP_3_ILLUS_IN_SYNC": "SINCRONIZZATI",
|
||||
"SELF_TITLE": "Per utenti esperti",
|
||||
"SELF_SUBTITLE": "Preferisci gestire i tuoi dati? Avvia il tuo server privato in pochi minuti.",
|
||||
"SELF_MASCOT_ALT": "Un koala che configura un server Docker",
|
||||
"SELF_COPY_CODE": "Copia Codice",
|
||||
"SELF_GITHUB_PACKAGES": "Visualizza tutti i tag delle immagini su GitHub Packages",
|
||||
"BOTTOM_TITLE": "Non sei convinto? Guarda tu stesso.",
|
||||
"BOTTOM_SUBTITLE": "KoalaSync è al 100% open source, senza pubblicità e senza tracciamento. Controlla il nostro codice su GitHub o installa direttamente l'estensione del browser.",
|
||||
"BOTTOM_MASCOT_ALT": "Un simpatico koala che tiene una pagina del repository GitHub vicino alla mascotte di GitHub Octocat",
|
||||
"SELF_GITHUB_PACKAGES": "Vedi tutte le versioni su GitHub",
|
||||
"BOTTOM_TITLE": "Ancora dubbi?",
|
||||
"BOTTOM_SUBTITLE": "KoalaSync è 100% open source, senza pubblicità o tracciamenti. Controlla il nostro codice su GitHub.",
|
||||
"BOTTOM_MASCOT_ALT": "Un koala con Octocat, la mascotte di GitHub",
|
||||
"FAQ_TITLE": "Domande Frequenti",
|
||||
"FAQ_SUBTITLE": "Tutto quello che c'è da sapere sulla visione dei video insieme in perfetta sincronia.",
|
||||
"FAQ_Q1": "Posso guardare Netflix, Emby o Jellyfin in sincronia con gli amici?",
|
||||
"FAQ_A1": "Sì! KoalaSync funziona su Netflix, YouTube, Twitch e qualsiasi sito web con un lettore video HTML5 — inclusi Emby, Jellyfin e server multimediali personalizzati. Installa l'estensione, crea una stanza, condividi il link di invito e tutti guarderanno in perfetta sincronia.",
|
||||
"FAQ_Q2": "KoalaSync è un'alternativa gratuita a Teleparty?",
|
||||
"FAQ_A2": "Gratuito al 100% e open source con licenza MIT. Nessun piano premium, nessuna funzionalità bloccata, nessuna pubblicità — tutto è incluso. A differenza di Teleparty, il server di inoltro di KoalaSync è solo in RAM, con zero raccolta dati o account utente richiesti.",
|
||||
"FAQ_Q3": "KoalaSync funziona con Emby, Jellyfin e server multimediali ospitati in proprio?",
|
||||
"FAQ_A3": "Sì. KoalaSync rileva qualsiasi elemento video HTML5 standard, il che significa che funziona perfettamente con Emby, Jellyfin, Plex e qualsiasi configurazione di streaming personalizzata. Puoi anche ospitare autonomamente il server di inoltro tramite Docker.",
|
||||
"FAQ_Q4": "Quali browser e piattaforme supportano KoalaSync?",
|
||||
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave e tutti i browser basati su Chromium. Installalo direttamente dal Chrome Web Store o da Firefox Add-ons — non è richiesta alcuna configurazione manuale.",
|
||||
"FAQ_Q5": "Tutti i miei amici devono avere l'estensione installata?",
|
||||
"FAQ_A5": "Sì, ogni partecipante deve avere KoalaSync installato — ma non è richiesta alcuna registrazione o creazione di account. Fai clic sul link di invito, installa l'estensione se non l'hai già fatto ed entrerai automaticamente nella stanza in pochi secondi.",
|
||||
"FAQ_Q6": "I miei dati sono al sicuro? E se non mi fido del server ufficiale?",
|
||||
"FAQ_A6": "Completamente al sicuro. Il server di inoltro di KoalaSync è solo in RAM — tutti i dati della sessione vengono eliminati permanentemente nel momento in cui lasci la stanza. Nessun database, nessun log, nessuna analisi, nessun account. E se preferisci il controllo totale: puoi ospitare autonomamente il tuo server di inoltro privato tramite Docker in pochi minuti. L'intera codebase è open source e controllabile su GitHub.",
|
||||
"FAQ_Q7": "Posso risolvere dialoghi sommessi ed esplosioni forti quando guardo video nel browser?",
|
||||
"FAQ_A7": "Sì. KoalaSync 2.2.0 include un compressore audio integrato che riduce la differenza tra le parti silenziose e quelle rumorose del tuo video. Funziona localmente tramite l'API Web Audio — nessun dato audio lascia mai il tuo computer. Apri il popup, crea una stanza, vai su Impostazioni → Audio Processing, attiva il compressore e scegli un preset. Funziona su Netflix, Prime Video, Disney+, YouTube e qualsiasi sito video HTML5.",
|
||||
"HOWTO_TITLE": "Come sincronizzare i video con gli amici usando KoalaSync",
|
||||
"HOWTO_DESC": "Guarda qualsiasi video in perfetta sincronia con gli amici su YouTube, Netflix, Twitch e altri. Nessuna registrazione, nessun tracciamento.",
|
||||
"HOWTO_STEP_1_NAME": "Installa l'estensione del browser",
|
||||
"HOWTO_STEP_1_TEXT": "Aggiungi KoalaSync al tuo browser dal Chrome Web Store o da Firefox Add-ons. Funziona su qualsiasi sito web con un elemento video.",
|
||||
"FAQ_SUBTITLE": "Tutto quello che c'è da sapere per guardare video in perfetta sincronia.",
|
||||
"FAQ_Q1": "Posso guardare Netflix o YouTube in sincronia?",
|
||||
"FAQ_A1": "Certamente! KoalaSync funziona su Netflix, YouTube, Twitch e qualsiasi sito con video HTML5. Basta installare l'estensione, creare una stanza e condividere il link.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync trasmette il video ai miei amici?",
|
||||
"FAQ_A_VIDEO_ACCESS": "No. KoalaSync non trasmette, ospita, condivide o aggira l’accesso ai contenuti video. Ogni persona guarda il video localmente nel proprio browser e deve avere il proprio accesso al sito o al servizio di streaming, ad esempio un proprio account Netflix. KoalaSync sincronizza solo le azioni di riproduzione come play, pausa e avanzamento.",
|
||||
"FAQ_Q2": "KoalaSync è gratuito?",
|
||||
"FAQ_A2": "Sì, al 100%. È open source con licenza MIT. Non ci sono funzioni a pagamento né pubblicità. A differenza di altre app, non raccogliamo dati utente.",
|
||||
"FAQ_Q3": "Funziona con Emby o Jellyfin?",
|
||||
"FAQ_A3": "Sì. KoalaSync è compatibile con qualsiasi player HTML5, inclusi server multimediali come Plex, Emby e Jellyfin.",
|
||||
"FAQ_Q4": "Quali browser sono supportati?",
|
||||
"FAQ_A4": "Chrome, Firefox, Edge, Opera, Brave e tutti i browser basati su Chromium.",
|
||||
"FAQ_Q5": "I miei amici devono installare l'estensione?",
|
||||
"FAQ_A5": "Sì, ogni partecipante deve avere KoalaSync. Non serve registrarsi: basta cliccare sul link e si entra subito nella stanza.",
|
||||
"FAQ_Q6": "I miei dati sono al sicuro?",
|
||||
"FAQ_A6": "Assolutamente. Usiamo solo la RAM del server per inoltrare i segnali; nulla viene salvato in database o log. Quando chiudi la stanza, i dati spariscono per sempre.",
|
||||
"FAQ_Q7": "Posso migliorare l'audio dei film?",
|
||||
"FAQ_A7": "Sì. KoalaSync include un compressore audio che rende le voci più chiare e attenua i rumori troppo forti. Lo trovi in Impostazioni → Elaborazione Audio.",
|
||||
"HOWTO_TITLE": "Come sincronizzare i video con gli amici",
|
||||
"HOWTO_DESC": "Guarda video in perfetta sincronia su YouTube, Netflix e Twitch. Senza account.",
|
||||
"HOWTO_STEP_1_NAME": "Installa l'estensione",
|
||||
"HOWTO_STEP_1_TEXT": "Aggiungi KoalaSync al tuo browser dalle store ufficiali.",
|
||||
"HOWTO_STEP_2_NAME": "Crea una stanza",
|
||||
"HOWTO_STEP_2_TEXT": "Apri il popup dell'estensione e fai clic su Crea Nuova Stanza. Il link di invito viene copiato automaticamente negli appunti.",
|
||||
"HOWTO_STEP_2_TEXT": "Apri l'estensione e crea una stanza. Copia il link di invito.",
|
||||
"HOWTO_STEP_3_NAME": "Condividi e sincronizza",
|
||||
"HOWTO_STEP_3_TEXT": "Invia il link di invito ai tuoi amici. Scegli una scheda video e goditi la riproduzione sincronizzata in tempo reale.",
|
||||
"FOOTER_MIT": "Open source con licenza MIT.",
|
||||
"FOOTER_RAM": "Nessun dato viene memorizzato sui nostri server. Inoltro basato esclusivamente su RAM.",
|
||||
"HOWTO_STEP_3_TEXT": "Invia il link ai tuoi amici e godetevi il film insieme.",
|
||||
"FOOTER_MIT": "Codice aperto con licenza MIT.",
|
||||
"FOOTER_RAM": "Nessun dato salvato. Retrasmissione basata su RAM.",
|
||||
"FOOTER_LEGAL": "Note Legali",
|
||||
"FOOTER_PRIVACY": "Informativa sulla Privacy",
|
||||
"FOOTER_PRIVACY": "Privacy",
|
||||
"MOCK_01": "Stanza Attiva",
|
||||
"MOCK_02": "Server Ufficiale",
|
||||
"MOCK_03": "Link di Invito",
|
||||
"MOCK_04": "Partecipanti nella Stanza",
|
||||
"MOCK_05": "YOU",
|
||||
"MOCK_06": "Playing",
|
||||
"MOCK_07": "Peer",
|
||||
"MOCK_08": "Lascia Stanza",
|
||||
"MOCK_09": "Seleziona Video",
|
||||
"MOCK_10": "Controllo Remoto",
|
||||
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Play",
|
||||
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pause",
|
||||
"MOCK_04": "Partecipanti",
|
||||
"MOCK_05": "TU",
|
||||
"MOCK_06": "In riproduzione",
|
||||
"MOCK_07": "Partecipante",
|
||||
"MOCK_08": "Esci dalla stanza",
|
||||
"MOCK_09": "Scegli Video",
|
||||
"MOCK_10": "Telecomando",
|
||||
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Riproduci",
|
||||
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pausa",
|
||||
"MOCK_13": "<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\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SYNC",
|
||||
"MOCK_14": "Salto dagli Altri",
|
||||
"MOCK_15": "Stato Ultima Attività",
|
||||
"MOCK_16": "In attesa di 1 partecipante (KoalaPC)...",
|
||||
"MOCK_17": "Salta e riproduci comunque",
|
||||
"MOCK_18": "Tuo Nome Utente",
|
||||
"MOCK_19": "Nascondi Schede Inutili",
|
||||
"MOCK_20": "Auto-Sync Prossimo Episodio",
|
||||
"MOCK_21": "Copia Automatica Invito",
|
||||
"MOCK_22": "Notifiche del Browser",
|
||||
"MOCK_23": "Risoluzione Problemi",
|
||||
"MOCK_24": "Rigenera ID Peer",
|
||||
"MOCK_25": "Usa questo se riscontri errori di \"Identità Duplicata\".",
|
||||
"MOCK_26": "Stato Connessione",
|
||||
"MOCK_27": "Connected",
|
||||
"MOCK_14": "Vai dagli altri",
|
||||
"MOCK_15": "Ultima attività",
|
||||
"MOCK_16": "In attesa di un amico (KoalaPC)...",
|
||||
"MOCK_17": "Salta l'attesa",
|
||||
"MOCK_18": "Tuo Nome",
|
||||
"MOCK_19": "Nascondi schede",
|
||||
"MOCK_20": "Sincro auto episodi",
|
||||
"MOCK_21": "Copia auto link",
|
||||
"MOCK_22": "Notifiche",
|
||||
"MOCK_23": "Risoluzione problemi",
|
||||
"MOCK_24": "Nuovo ID Utente",
|
||||
"MOCK_25": "Risolve errori di 'Identità duplicata'.",
|
||||
"MOCK_26": "Connessione",
|
||||
"MOCK_27": "Connesso",
|
||||
"MOCK_28": "Copia Log",
|
||||
"MOCK_29": "Info Debug Video",
|
||||
"MOCK_30": "Cronologia Azioni Completa",
|
||||
"MOCK_31": "Log (Ultimi 50)",
|
||||
"MOCK_29": "Debug Video",
|
||||
"MOCK_30": "Cronologia",
|
||||
"MOCK_31": "Log",
|
||||
"MOCK_32": "PULISCI",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync non è affiliato, approvato o associato a Netflix, Disney+, Amazon, YouTube, Twitch o qualsiasi altra piattaforma di streaming. Tutti i marchi registrati sono di proprietà dei rispettivi titolari.",
|
||||
"FOOTER_SUPPORT": "Offrimi un caffè su Ko-Fi"
|
||||
"FOOTER_DISCLAIMER": "KoalaSync non è affiliato a Netflix, YouTube o altre piattaforme. I marchi appartengono ai rispettivi proprietari.",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"ALT_TELEPARTY_TITLE": "KoalaSync vs Teleparty: quale strumento scegliere per le watch party?",
|
||||
"ALT_TELEPARTY_META_DESC": "Un confronto pratico tra KoalaSync e Teleparty per le watch party: chat integrata, supporto mobile, sincronizzazione video nel browser, server multimediali self-hosted e natura open-source.",
|
||||
"ALT_TELEPARTY_INTRO": "Sia KoalaSync che Teleparty risolvono lo stesso fastidioso problema: guardare qualcosa insieme senza dover ricorrere alla classica procedura del '3, 2, 1, via'. Semplicemente, adottano approcci diversi.<br><br>Teleparty è la scelta ideale se cerchi una chat integrata o se vuoi guardare i contenuti da dispositivo mobile. KoalaSync è più indicato per le watch party su browser desktop, quando stai già comunicando tramite Discord, Teamspeak o un'altra chiamata vocale e vuoi semplicemente mantenere sincronizzata la riproduzione.<br><br>Il motivo principale per cui ho creato KoalaSync è la flessibilità. È progettato per funzionare con quasi tutti i siti web che utilizzano un normale lettore video del browser, non solo con una lista predefinita di piattaforme. Ciò include configurazioni self-hosted come Emby, Jellyfin e Plex, domini locali o privati e molte pagine web che ospitano video.",
|
||||
"ALT_TELEPARTY_WHEN_TELE_TITLE": "Quando Teleparty può ancora essere la scelta giusta",
|
||||
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty può rivelarsi la scelta migliore se desideri che la chat sia parte integrante dello strumento stesso per la watch party. Attualmente KoalaSync non dispone di una chat integrata; presuppone che tu stia già comunicando altrove, ad esempio su Discord, Teamspeak, WhatsApp, Signal o tramite una normale chiamata.<br><br>Il supporto mobile è un altro fattore chiave. Teleparty offre un'app dedicata, mentre KoalaSync funziona al momento solo su browser desktop. Se vuoi sincronizzare Netflix su smartphone o tablet, KoalaSync non è lo strumento adatto in questo momento.<br><br>Quindi, se la chat e il supporto mobile sono gli aspetti a cui tieni di più, Teleparty potrebbe adattarsi meglio alle tue esigenze.",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "Quando KoalaSync è la scelta ideale",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_BODY": "KoalaSync è la soluzione ideale se cerchi uno strumento flessibile per sincronizzare la riproduzione video tra browser desktop, anziché una soluzione vincolata a una lista ristretta di piattaforme.<br><br>Funziona con quasi tutti i siti web che utilizzano i normali lettori video dei browser. È particolarmente utile per Emby, Jellyfin, Plex, server multimediali privati, domini locali o siti video minori che solitamente vengono ignorati dai classici strumenti per le sessioni di visione condivisa.<br><br>È inoltre gratuito, open source, non richiede la creazione di account e può essere ospitato autonomamente se desideri gestire il tuo relay. L'ho creato perché volevo una soluzione rispettosa della privacy che funzionasse con la mia configurazione Emby, non l'ennesima piattaforma chiusa limitata a pochi grandi servizi di streaming.",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "La differenza principale",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty è concepito principalmente come strumento per la visione condivisa, completo di chat e supporto mobile. KoalaSync, invece, agisce più come un livello flessibile di sincronizzazione per i video riprodotti nel browser.<br><br>Può sembrare una differenza da poco, ma cambia notevolmente le modalità d'uso. Se cerchi un'applicazione completa per la visione condivisa con chat integrata, Teleparty è la scelta giusta. Se invece disponi già di una chat vocale e vuoi semplicemente mantenere sincronizzati i controlli video tra diversi browser, KoalaSync è la soluzione più semplice ed efficace.",
|
||||
"ALT_TELEPARTY_LIMITS_TITLE": "Limiti dichiarati apertamente",
|
||||
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync non fa miracoli e non cerca di nascondere i propri limiti. Attualmente funziona solo su browser desktop; non dispone di chat integrata né di un'app per dispositivi mobili.<br><br>La compatibilità dipende anche dal browser e dal lettore video utilizzato. KoalaSync dovrebbe funzionare con quasi tutti i normali lettori video dei browser, ma i siti web possono modificare in qualsiasi momento i propri lettori, la gestione dei DRM o le restrizioni del browser.<br><br>KoalaSync non è affiliato a Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex o qualsiasi altro marchio qui menzionato. I nomi sono utilizzati esclusivamente per illustrare i tipici scenari di utilizzo.",
|
||||
"ALT_TELEPARTY_SUMMARY_TITLE": "Riepilogo",
|
||||
"ALT_TELEPARTY_SUMMARY_BODY": "Se cerchi una chat integrata o il supporto per dispositivi mobili, Teleparty potrebbe essere la scelta più adatta. Se invece preferisci una soluzione gratuita e open-source per la sincronizzazione tra browser desktop, compatibile con quasi tutti i comuni lettori video — inclusi i media server self-hosted come Emby, Jellyfin e Plex — KoalaSync è probabilmente l'opzione migliore.",
|
||||
"ALT_SCREEN_TITLE": "KoalaSync vs condivisione schermo: quando è meglio la sincronizzazione video?",
|
||||
"ALT_SCREEN_META_DESC": "Un confronto pratico tra KoalaSync e la condivisione dello schermo per serate film a distanza, analizzando controllo della riproduzione, qualità video, privacy, sottotitoli e visione tramite browser.",
|
||||
"ALT_SCREEN_INTRO": "La condivisione dello schermo funziona e, a dire il vero, a volte è sufficiente. Una persona condivide il proprio schermo, le altre guardano e il gioco è fatto.<br><br>Tuttavia, per serate film più lunghe o per guardare insieme degli episodi, può diventare scomoda. La qualità video dipende dalla velocità di upload, dalla CPU e dal browser di chi condivide, oltre che dalla compressione applicata da app come Discord, Teamspeak, Zoom o altre piattaforme di chiamata. Inoltre, solo una persona ha il controllo effettivo della riproduzione.",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_TITLE": "Quando la condivisione dello schermo è sufficiente",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_BODY": "La condivisione dello schermo è una buona soluzione quando si cerca la configurazione più rapida possibile. Se solo una persona ha accesso al video o se a tutti va bene che una sola persona controlli la riproduzione, la pausa e lo scorrimento del video, potrebbe essere l'opzione più semplice.<br><br>È una soluzione valida anche quando la qualità non è una priorità assoluta, quando nessuno ha bisogno di impostazioni personalizzate per sottotitoli o audio e quando chi condivide lo schermo si sente a proprio agio nel mostrare quella specifica finestra del browser o l'intero schermo.",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_TITLE": "Quando la condivisione schermo diventa scomoda",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_BODY": "Lo svantaggio principale è che non tutti guardano il video tramite il proprio lettore. Una persona trasmette il proprio schermo e tutti gli altri vedono solo quel flusso video.<br><br>Ciò può comportare una qualità video inferiore, audio compresso, ritardi nella riproduzione e un controllo limitato per gli altri partecipanti. Anche i sottotitoli e le tracce audio sono quelli scelti da chi effettua la condivisione.<br><br>C'è poi l'aspetto della privacy. Se cambi finestra o scheda, gli altri potrebbero vedere brevemente le schede del tuo browser, le email, Reddit, le chat o altri contenuti privati. È possibile aggirare il problema usando una finestra separata, ma è comunque un aspetto da tenere a mente.",
|
||||
"ALT_SCREEN_WHY_CLEANER_TITLE": "Perché KoalaSync offre un'esperienza più pulita",
|
||||
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSync non trasmette il tuo schermo. Ognuno guarda il video localmente sul proprio browser e KoalaSync si limita a sincronizzare le azioni di riproduzione, come avvio, pausa e scorrimento.<br><br>Questo permette a ciascuno di utilizzare la modalità a schermo intero sul proprio dispositivo, di impostare i sottotitoli o l'audio secondo le proprie preferenze (se il lettore lo consente) e di mantenere separata la chat vocale su Discord, Teamspeak o altre piattaforme.<br><br>Per le serate film, questa soluzione risulta molto più ordinata rispetto alla condivisione dello schermo da parte di una sola persona. È particolarmente utile per configurazioni basate su browser come Emby, Jellyfin, Plex, file locali aperti nel browser, domini privati o altri siti web dotati di lettori video standard.",
|
||||
"ALT_SCREEN_LIMITS_TITLE": "Limiti reali",
|
||||
"ALT_SCREEN_LIMITS_BODY": "KoalaSync non sostituisce una chiamata vocale. Se volete parlare mentre guardate il contenuto, potete continuare a usare Discord, Teamspeak, Signal, WhatsApp o qualsiasi altro strumento abituale.<br><br>Ciascun partecipante deve inoltre poter accedere al video o alla pagina dal proprio dispositivo. KoalaSync non aggira abbonamenti, accessi, DRM, restrizioni geografiche o regole delle piattaforme.<br><br>Al momento è utilizzabile solo tramite browser desktop, non dispone di funzioni di chat o voce integrate e la compatibilità dipende sempre dal browser e dal lettore video.",
|
||||
"ALT_SCREEN_SUMMARY_TITLE": "Riepilogo",
|
||||
"ALT_SCREEN_SUMMARY_BODY": "La condivisione dello schermo è l'opzione più semplice quando si cerca una soluzione rapida e non si dà molta importanza alla qualità, alla privacy o al controllo individuale. KoalaSync è la scelta ideale quando tutti devono guardare il contenuto localmente sul proprio browser, offrendo un'esperienza che ricorda più da vicino la visione condivisa reale.",
|
||||
"ALT_TRY_KOALASYNC": "Prova KoalaSync"
|
||||
}
|
||||
|
||||
+32
-2
@@ -7,6 +7,7 @@
|
||||
"OG_LOCALE": "ja_JP",
|
||||
"META_TITLE": "KoalaSync | Netflix、YouTube、あらゆる動画を友達と同期視聴",
|
||||
"META_DESCRIPTION": "Netflix、YouTube、Twitch、およびあらゆるHTML5動画を友達と完全に同期して視聴。ChromeおよびFirefox用の無料のオープンソースブラウザ拡張機能。登録不要。",
|
||||
"SCHEMA_APP_DESC": "Netflix、YouTube、Twitch、およびあらゆるHTML5動画を友達と完全に同期して視聴。ChromeおよびFirefox用の無料のオープンソースブラウザ拡張機能。登録不要。",
|
||||
"OG_TITLE": "KoalaSync | Netflix、Emby、Jellyfin、ほぼすべての動画を友達と同期",
|
||||
"OG_DESCRIPTION": "Netflix、Emby、Jellyfin、YouTube、Twitch、ほぼすべてのHTML5動画を完全に同期して視聴。ChromeおよびFirefox用のプライバシー優先のオープンソースブラウザ拡張機能。",
|
||||
"TWITTER_TITLE": "KoalaSync | Netflix、Emby、Jellyfin、ほぼすべての動画を友達と同期 – ブラウザ拡張機能",
|
||||
@@ -75,7 +76,7 @@
|
||||
"COMP_FEAT_5_TELE": "サポートされているサイトのみ",
|
||||
"COMP_FEAT_6_NAME": "ローカライズ / 翻訳",
|
||||
"COMP_FEAT_6_DESC": "利用可能なインターフェース言語と翻訳サポート。",
|
||||
"COMP_FEAT_6_KOALA": "13の言語(今後も追加予定)",
|
||||
"COMP_FEAT_6_KOALA": "15の言語(今後も追加予定)",
|
||||
"COMP_FEAT_6_TELE": "英語のみ",
|
||||
"COMP_FEAT_7_NAME": "オーディオコンプレッサー / 音量均等化",
|
||||
"COMP_FEAT_7_DESC": "静かな会話と大きな効果音のバランスを取る内蔵オーディオノーマライゼーション",
|
||||
@@ -116,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "完璧に同期して動画を一緒に楽しむために必要なすべての情報。",
|
||||
"FAQ_Q1": "友達と一緒にNetflix、Emby、Jellyfinを同期視聴できますか?",
|
||||
"FAQ_A1": "はい!KoalaSyncはNetflix, YouTube, TwitchおよびHTML5ビデオプレーヤーを備えたあらゆるウェブサイト(Emby, Jellyfin, 独自のメディアサーバーを含む)で動作します。拡張機能をインストールし、ルームを作成して招待リンクを共有するだけで、全員で完全に同期して視聴できます。",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync は友達に動画を配信しますか?",
|
||||
"FAQ_A_VIDEO_ACCESS": "いいえ。KoalaSync は動画コンテンツを配信、ホスト、共有したり、アクセス制限を回避したりしません。各参加者は自分のブラウザで動画をローカルに視聴し、Netflix アカウントなど、そのウェブサイトやストリーミングサービスへの自分自身のアクセス権が必要です。KoalaSync は再生、一時停止、シークなどの再生操作だけを同期します。",
|
||||
"FAQ_Q2": "KoalaSyncはTelepartyの無料の代替ツールですか?",
|
||||
"FAQ_A2": "MITライセンスのもとで100%無料かつオープンソース。有料プラン、制限された機能、広告などは一切ありません。Telepartyとは異 nel、KoalaSyncのリレーサーバーはメモリ上のみで動作し、データ収集やユーザーアカウント作成は不要です。",
|
||||
"FAQ_Q3": "KoalaSyncはEmby、Jellyfin、およびセルフホストのメディアサーバーで動作しますか?",
|
||||
@@ -175,5 +178,32 @@
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSyncは、Netflix、Disney+、Amazon、YouTube、Twitch、またはその他のストリーミングプラットフォームと提携、承認、または関連付けられているものではありません。すべての商標はそれぞれの所有者に帰属します。",
|
||||
"FOOTER_SUPPORT": "Ko-Fiでコーヒーをおごる"
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"ALT_TELEPARTY_TITLE": "KoalaSync vs Teleparty:どちらのウォッチパーティツールを使うべき?",
|
||||
"ALT_TELEPARTY_META_DESC": "ウォッチパーティ向けツール、KoalaSyncとTelepartyの実践的比較。チャット機能、モバイル対応、ブラウザでの動画同期、セルフホスト型メディアサーバー、オープンソース利用といった観点から解説します。",
|
||||
"ALT_TELEPARTY_INTRO": "KoalaSyncとTelepartyは、どちらも「3、2、1、再生!」と声を掛け合う手間をかけずに、一緒に動画を楽しむという共通の課題を解決してくれます。ただ、そのアプローチは異なります。<br><br>チャット機能が組み込まれていることや、モバイル端末で視聴できることを重視するなら、Telepartyが最適です。一方、KoalaSyncは、DiscordやTeamSpeakなどで既に通話しながら、ブラウザ上で動画の再生タイミングだけを同期させたいという、デスクトップブラウザでの利用に適しています。<br><br>私がKoalaSyncを開発した主な理由は「柔軟性」です。特定のプラットフォームだけでなく、一般的なブラウザ用動画プレーヤーを使用するほぼすべてのウェブサイトで動作するように設計されています。これには、Emby、Jellyfin、Plexといったセルフホスト環境や、ローカル/プライベートドメイン、そしてブラウザ上の一般的な動画ページなどが含まれます。",
|
||||
"ALT_TELEPARTY_WHEN_TELE_TITLE": "Telepartyが適しているケース",
|
||||
"ALT_TELEPARTY_WHEN_TELE_BODY": "ウォッチパーティツール自体にチャット機能が組み込まれていることを望むなら、Telepartyの方が良い選択肢となるでしょう。KoalaSyncには現在、チャット機能が搭載されていません。Discord、TeamSpeak、WhatsApp、Signal、あるいは通常の通話など、別の手段で既に会話していることを前提としています。<br><br>もう一つの大きな理由はモバイル対応です。Telepartyにはアプリがありますが、KoalaSyncは現時点ではデスクトップブラウザでしか動作しません。スマートフォンやタブレットでNetflixを同期再生したい場合、今のところKoalaSyncは適していません。<br><br>つまり、チャット機能やモバイル対応を何よりも重視するのであれば、Telepartyの方があなたの利用スタイルに合っているかもしれません。",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "KoalaSyncが適している場合",
|
||||
"ALT_TELEPARTY_WHEN_KOALA_BODY": "特定のプラットフォームに縛られるツールではなく、柔軟性の高いデスクトップブラウザ用同期ツールをお探しなら、KoalaSyncが適しています。<br><br>一般的なブラウザの動画プレーヤーを使用するほぼすべてのウェブサイトで動作します。これは特に、Emby、Jellyfin、Plex、プライベートメディアサーバー、ローカルドメイン、あるいは従来のウォッチパーティツールが見落としがちな小規模な動画サイトなどで役立ちます。<br><br>また、無料かつオープンソースであり、アカウント登録も不要です。独自のリレーサーバーを運用したい場合は、セルフホストも可能です。私がこのツールを開発したのは、一部の大手ストリーミングサイト専用の閉鎖的なプラットフォームではなく、自分のEmby環境で動作し、プライバシーに配慮したツールが欲しかったからです。",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "主な違い",
|
||||
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Telepartyは、チャットやモバイル対応を備えた「ウォッチパーティ(同時視聴)」向け製品です。一方、KoalaSyncは、ブラウザ上の動画再生を同期させるための、より柔軟な同期レイヤーと言えます。<br><br>些細な違いに聞こえるかもしれませんが、これによって利用シーンが大きく変わります。チャット機能が組み込まれた総合的なウォッチパーティアプリが必要なら、Telepartyが適しています。すでにボイスチャット環境があり、単にブラウザ間で動画の再生状態を同期させたいだけであれば、よりシンプルなKoalaSyncが適しています。",
|
||||
"ALT_TELEPARTY_LIMITS_TITLE": "制限事項について",
|
||||
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSyncは魔法のツールではなく、その制限を隠そうともしていません。現時点ではデスクトップブラウザでのみ動作し、チャット機能やモバイルアプリは搭載されていません。<br><br>互換性はブラウザや動画プレーヤーにも依存します。KoalaSyncは一般的なブラウザの動画プレーヤーのほとんどで動作するはずですが、ウェブサイト側がプレーヤー、DRMの挙動、あるいはブラウザへの制限などをいつでも変更する可能性があります。<br><br>KoalaSyncは、Netflix、Disney+、Prime Video、YouTube、Teleparty、Emby、Jellyfin、Plex、その他ここで言及されているブランドとは一切関係ありません。これらの名称は、一般的な利用シーンを説明するために使用されているに過ぎません。",
|
||||
"ALT_TELEPARTY_SUMMARY_TITLE": "概要",
|
||||
"ALT_TELEPARTY_SUMMARY_BODY": "チャット機能やモバイル対応を重視するなら、Telepartyの方が適しているかもしれません。一方、無料かつオープンソースで、デスクトップブラウザでの同期が可能、さらにEmby、Jellyfin、Plexといったセルフホスト型メディアサーバーを含む、一般的な動画プレーヤーのほとんどで動作するツールをお探しなら、KoalaSyncの方が適しているでしょう。",
|
||||
"ALT_SCREEN_TITLE": "KoalaSyncと画面共有:動画同期の方が優れているのはどんな時?",
|
||||
"ALT_SCREEN_META_DESC": "リモートでの映画鑑賞会におけるKoalaSyncと画面共有の実践的な比較。再生制御、画質、プライバシー、字幕、ブラウザでの視聴といった観点から解説します。",
|
||||
"ALT_SCREEN_INTRO": "画面共有は有効な手段であり、正直なところ、それで十分な場合もあります。一人が画面を共有し、他の全員がそれを見るだけで済みますから。<br><br>しかし、長時間の映画鑑賞やシリーズ作品を一緒に見るとなると、煩わしさを感じることもあります。画質は、共有者のアップロード速度、CPU、ブラウザ、そしてDiscord、TeamSpeak、Zoomなどの通話アプリによる圧縮処理に左右されます。また、再生を実際に操作できるのは一人のみです。",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_TITLE": "画面共有で十分な場合",
|
||||
"ALT_SCREEN_WHEN_ENOUGH_BODY": "とにかく手早く準備を済ませたいなら、画面共有で問題ありません。もし一人だけが動画にアクセスできる場合……あるいは、一人が再生・一時停止・シーク(再生位置の移動)を操作することに全員が納得しているなら、それが最もシンプルな選択肢かもしれません。<br><br>画質がそれほど重要ではなく、個別の字幕や音声設定を必要とする人がおらず、かつ共有者がブラウザウィンドウや画面をそのまま見せても構わない場合には、理にかなった方法と言えます。",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_TITLE": "画面共有が煩わしくなる場合",
|
||||
"ALT_SCREEN_WHEN_ANNOYING_BODY": "最大の欠点は、全員がそれぞれのプレイヤーで視聴するわけではないという点です。一人が画面をストリーミングし、他の全員はそのストリーム映像を見るだけになります。<br><br>その結果、画質の低下、音声の圧縮、再生の遅延が生じたり、他の参加者が操作できる範囲が狭まったりする可能性があります。字幕や音声トラックも、共有者が選択したものに固定されます。<br><br>プライバシーの問題もあります。ウィンドウやタブを切り替える際、ブラウザのタブ、メール、Reddit、チャット、その他の個人的な情報が一時的に見えてしまう可能性があります。別のウィンドウを使うことで回避はできますが、やはり考慮すべき点ではあります。",
|
||||
"ALT_SCREEN_WHY_CLEANER_TITLE": "KoalaSyncがすっきり感じられる理由",
|
||||
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSyncは画面をストリーミングしません。全員がそれぞれのブラウザでローカルに動画を視聴し、KoalaSyncは再生・一時停止・シークといった操作の同期のみを行います。<br><br>つまり、全員が自分のデバイスでフルスクリーン表示を利用したり、プレイヤーが対応していれば個別の字幕や音声設定を使ったりできるほか、DiscordやTeamSpeakなどの通話ツールで音声チャットを別に行うことも可能です。<br><br>映画鑑賞会などでは、一人が画面を配信するよりも、はるかにすっきりとした体験になります。特に、Emby、Jellyfin、Plex、ブラウザで開いたローカルファイル、プライベートドメイン、あるいは一般的なブラウザ用動画プレイヤーを備えたウェブサイトなど、ブラウザベースの環境で利用する場合に非常に便利です。",
|
||||
"ALT_SCREEN_LIMITS_TITLE": "正直なところ、制限事項も",
|
||||
"ALT_SCREEN_LIMITS_BODY": "KoalaSyncは音声通話の代わりになるものではありません。視聴しながら会話をする場合は、Discord、TeamSpeak、Signal、WhatsAppなど、普段お使いのツールをそのまま利用します。<br><br>また、参加者全員がそれぞれのデバイスでその動画やページにアクセスできる必要があります。KoalaSyncは、サブスクリプション、ログイン、DRM(デジタル著作権管理)、地域制限、プラットフォームの利用規約などを回避するものではありません。<br><br>現在はデスクトップ版ブラウザのみに対応しており、チャットや音声通話機能は内蔵されていません。また、動作の可否は使用するブラウザや動画プレーヤーに依存します。",
|
||||
"ALT_SCREEN_SUMMARY_TITLE": "概要",
|
||||
"ALT_SCREEN_SUMMARY_BODY": "画質やプライバシー、個別の操作性をあまり気にせず、手軽に済ませたい場合は「画面共有」が最も簡単な選択肢です。一方、参加者それぞれが自身のブラウザで動画を再生し、実際に同じ空間で一緒に視聴しているような感覚を重視するなら、KoalaSyncの方が適しています。",
|
||||
"ALT_TRY_KOALASYNC": "KoalaSyncを試す"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user