Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ed80856803 | |||
| 102031e0d2 | |||
| 503c7d6dc4 | |||
| 77793c8c6e | |||
| 4fbf309e5a | |||
| aa61a24351 | |||
| 2ee5c83ee6 | |||
| ca1cfdb382 | |||
| ef7b1f2e5f | |||
| 10fdaa23fc | |||
| 6ba5e1b10b | |||
| 6e234fb8fd | |||
| 9f553b4f8f | |||
| 131afadc1d | |||
| c62da66b06 | |||
| 34f0c2b265 | |||
| af59b4c64c | |||
| af8184420c | |||
| 45e1e3defe | |||
| e67b0c564d | |||
| f54a38b656 | |||
| 5216fde641 | |||
| 39fe2cbd11 | |||
| 03d5dbda66 | |||
| 0c0cbbc090 | |||
| 0f4ad39fb4 | |||
| cfbb070c57 | |||
| 0caf19cf18 | |||
| ff31f56911 | |||
| 54d03df0af | |||
| 4f1016fa87 | |||
| 3e7afd7592 | |||
| 6c6d4dc2a2 | |||
| 498d5bd91b | |||
| bfd1177a14 | |||
| 15f4f5a12e | |||
| fa39be7d40 | |||
| 5fd00f0bda | |||
| dca9c6ed07 | |||
| d8634744ea | |||
| 2031f76bee | |||
| 3757308117 | |||
| e4fae56509 | |||
| 766bc1760c | |||
| 4949b04d07 | |||
| 6ba81d66a4 | |||
| d5ed1083bc | |||
| 78d576c7f6 | |||
| 08ce689f4c | |||
| bf48d1889b | |||
| 39788e5bd9 | |||
| c134e1bfae | |||
| cad4b4a6db | |||
| 9e2abadf5a | |||
| f5db39b77b | |||
| 85d05b85c9 | |||
| 6b438f2d37 | |||
| 7bba36c505 | |||
| f93937f5f3 | |||
| da12dc07a7 | |||
| 198064b720 | |||
| def0ad6ded | |||
| 026bbc65b2 | |||
| 854be35474 | |||
| 3506eb0331 | |||
| 0a34d804fb | |||
| a1e882cfa7 | |||
| c88f6fb121 | |||
| fe047dea2e | |||
| 464f5f466a | |||
| 9f00645f58 | |||
| f95095f2cd | |||
| be91d07c56 | |||
| a6bc7cae48 | |||
| d0c4b3740d | |||
| 67a7f6e663 | |||
| 7499eafe4e | |||
| 3fe8ca18e4 | |||
| f0ccc0c082 | |||
| d59fc4777d | |||
| 3ad2459558 | |||
| 839f4d0761 | |||
| 8838bf20b7 | |||
| 9d849a2996 | |||
| 6d5661ea45 | |||
| 886550408a | |||
| 28694c22bb | |||
| a2a56f2b17 | |||
| 742876e415 | |||
| bcd956a8db | |||
| 1e329c0c52 | |||
| a7481d42a2 | |||
| f9577aace9 |
@@ -0,0 +1,2 @@
|
||||
# These are supported funding model platforms
|
||||
ko_fi: koaladev
|
||||
@@ -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,21 +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 }}
|
||||
@@ -27,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: |
|
||||
@@ -35,7 +43,8 @@ jobs:
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: server/Dockerfile
|
||||
@@ -43,14 +52,32 @@ 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
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
release-extension:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
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
|
||||
@@ -67,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
|
||||
@@ -78,13 +105,26 @@ jobs:
|
||||
jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json
|
||||
echo " ✓ website/version.json -> version $VERSION, date $DATE"
|
||||
|
||||
# 5. website/template.html — SoftwareApplication schema
|
||||
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
|
||||
echo " ✓ website/template.html -> softwareVersion $VERSION"
|
||||
|
||||
# 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
|
||||
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:
|
||||
@@ -92,11 +132,26 @@ jobs:
|
||||
|
||||
- name: Build Extensions
|
||||
run: |
|
||||
npm install
|
||||
npm ci
|
||||
npm run build:extension
|
||||
|
||||
- name: Generate artifact attestation for extensions
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: dist/koalasync-*.zip
|
||||
|
||||
- name: Build Website
|
||||
run: node website/build.cjs
|
||||
|
||||
- name: Upload Website Artifacts
|
||||
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
|
||||
|
||||
@@ -40,5 +41,8 @@ coverage/
|
||||
# the root 'shared/' remains the Single Source of Truth.
|
||||
extension/shared/
|
||||
|
||||
# Auto-generated website build output
|
||||
website/www/
|
||||
|
||||
# Temporary scratch files
|
||||
scratch/
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.7] — 2026-06-03
|
||||
|
||||
### Added
|
||||
- Added a `DEBUG_LOGGING` environment variable to the relay server (defaulting to `"0"` / disabled) to prevent console spam from verbose connection (`CONN`), room activity (`ROOM`, `DEDUPE`), and `CORS` events under load. Critical logs like `SERVER`, `SECURITY`, `AUTH`, and `ERROR` remain enabled at all times.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.6] — 2026-06-03
|
||||
|
||||
### Performance & Security Hardening
|
||||
- Optimized failed authentication attempts cache eviction algorithm to $O(1)$ by exploiting Javascript `Map` insertion-order properties. This completely removes the previous array copying and sorting bottleneck, neutralizing a potential main-thread blocking DoS vector under heavy brute-force password traffic.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.5] — 2026-06-03
|
||||
|
||||
### Security & Hardening
|
||||
- Hardened extension room idle auto-leave detection to correctly recognize when the target tab's video heartbeat goes stale (e.g., after tab navigation or media closure).
|
||||
- Exported cleaner graceful shutdown and lifecycle methods (`stopServerForTests`) from the relay server to prevent socket leaks and port-binding conflicts during verify checks.
|
||||
|
||||
### Added
|
||||
- Added a validation step in `test-locales.js` to ensure the supported language list in `extension/i18n.js` is perfectly synchronized with the actual JSON translation files in the locales directory.
|
||||
- Added a robust route verification test suite (`scripts/test-server-routes.mjs`) covering rate limit throttling, caching headers, and admin metrics access control.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.4] — 2026-06-03
|
||||
|
||||
### Security & Hardening
|
||||
- Hardened relay health endpoints against simple flood traffic: `GET /` and `GET /health` are now limited to 10 requests per minute per client IP.
|
||||
- Added lazy 60-second server-side caching for `GET /`, basic `/health`, and admin `/health` JSON responses to reduce repeated health-check work under noisy polling.
|
||||
- Added stricter brute-force throttling for invalid admin metrics bearer attempts.
|
||||
- Added startup warning for short `ADMIN_METRICS_TOKEN` values and documented that production Node ports must stay private behind Caddy or another trusted reverse proxy.
|
||||
- Lowered the default maximum peers per room to 25.
|
||||
|
||||
### Added
|
||||
- Optional privacy-preserving admin metrics on `/health` when `ADMIN_METRICS_TOKEN` is configured and a valid bearer token is supplied. Metrics are aggregate-only and exclude room IDs, peer IDs, usernames, IP addresses, media titles, passwords, and other user-level data.
|
||||
|
||||
### Changed
|
||||
- Removed `bcryptjs`; temporary room passwords continue to use keyed SHA-256/HMAC hashing as documented.
|
||||
- Public room discovery is now rate-limited server-side to one refresh every 10 seconds per socket, with the extension refresh button locked for 11 seconds.
|
||||
|
||||
### Fixed
|
||||
- Improved Shadow DOM video detection so real embedded players are not hidden by smaller light-DOM preview or placeholder videos.
|
||||
- Fixed join-button timeout cleanup after join status responses.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] — 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
|
||||
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] — 2026-06-01
|
||||
|
||||
### Fixed
|
||||
- Video detection on Prime Video: `findVideo()` now scores all video elements by size, duration, and mute state instead of picking the first one. Fixes 0×0 placeholder being selected over the actual player.
|
||||
- History entries in debug report showing `?` instead of action names.
|
||||
- Prime Video status in compatibility matrix updated to reflect partial support.
|
||||
|
||||
### Added
|
||||
- Multi-video overview table in Copy Debug Report when a page has more than one `<video>` element. Shows resolution, mute state, playback state, readyState, duration, and marks the currently targeted video.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.0] — 2026-06-01
|
||||
|
||||
### 🌍 Multi-Language Extension (Biggest Feature!)
|
||||
- **6-Language UI**: The browser extension is now fully translated into **English, German, French, Spanish, Portuguese (Brazilian), and Russian**. Switch languages instantly in Settings without reload.
|
||||
- **Real-Time i18n**: Every label, button, tooltip, toast notification, empty state, and onboarding guide updates dynamically when the language changes.
|
||||
|
||||
### New Features
|
||||
- **Copy Debug Report (Markdown)**: The *Copy Logs* button in the Status tab now copies a fully formatted Markdown debug report — system info, connection status, video diagnostics, action history, and logs. One click, paste into a GitHub issue, all debugging data ready.
|
||||
- **Platform Auto-Detection**: The Dev tab now identifies streaming platforms (YouTube, Netflix, Twitch, Prime Video, Disney+, HBO Max, Vimeo, Dailymotion) and displays the detected platform.
|
||||
- **Enhanced Video Debug Info**: 20+ new fields in the Status tab including network state, buffered ranges, dimensions (with 0×0 warning), media error codes, shadow DOM status, seeking/ended/loop flags, volume, playback speed, and data attributes.
|
||||
- **No-Video Diagnostic Mode**: When no video is found, the Status tab shows platform, page title, video count, shadow DOM presence, and MediaSession data to help troubleshoot.
|
||||
|
||||
### Changed
|
||||
- **New TwoPointZero Branding**: Updated extension icons (16/32/48/96/128px).
|
||||
- **Larger Popup Logo**: Extension popup icon increased to 48px.
|
||||
- **Prime Video Unblocked**: Removed `amazon.` from the tab blacklist so Amazon/Prime Video tabs appear in the video selector.
|
||||
- **Improved Debug Report**: Full User-Agent string for accurate browser identification, UTC timestamp, connection details including server URL and room info.
|
||||
- **Smart Disconnect**: Improved disconnect handling when leaving rooms.
|
||||
- **Human-Readable Room IDs**: Expanded word lists for friendlier room names.
|
||||
- **Custom Server Support**: WEB_JOIN_REQUEST and join button for custom server invite flows.
|
||||
- **Reconnection Strategy**: Custom server reconnection improvements.
|
||||
- **Episode-Aware Sync**: Command sequencing with smarter episode transition detection and echo suppression for smoother series binges.
|
||||
- **Sync Status Refinements**: YouTube and Twitch sync behavior improved.
|
||||
- **No External Dependencies**: Extension remains dependency-free with no library overhead.
|
||||
|
||||
### Fixed
|
||||
- Hardcoded strings, missing translation keys, and Service Worker notification race conditions.
|
||||
|
||||
---
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
- **MAJOR** (x.0.0): Breaking protocol changes, architecture rewrites, or major feature milestones.
|
||||
- **MINOR** (0.x.0): New features, significant enhancements, new translations, or UI redesigns.
|
||||
- **PATCH** (0.0.x): Bug fixes, minor improvements, and documentation updates. PATCH releases may not receive individual changelog entries if bundled with a MINOR release.
|
||||
@@ -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,19 +6,19 @@
|
||||
|
||||
<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.4.6-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"><a href="CHANGELOG.md"><b>New v2.0.7 Release!</b> — See what's changed</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.4.6 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture.
|
||||
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture. We keep no logs of your sessions or synchronizations. *We don't track you. We only track our server* (relying on the [aggregated, anonymous, non-personal metrics](https://syncserver.koalastuff.net/health) provided under `/health`).
|
||||
* **📡 Direct Logic**: Manual Socket.IO wire implementation for reliable synchronization.
|
||||
* **🛠️ Clean Build**: Dependency-free extension runtime with no library overhead.
|
||||
* **🌐 Universal**: Works on any website with a `<video>` tag.
|
||||
@@ -33,6 +33,7 @@
|
||||
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
|
||||
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
|
||||
- **Seamless Invitations**: Smart links that automatically configure server and room credentials for your friends.
|
||||
- **Smart Audio Compressor**: Tired of constantly riding the volume? Automatically balance out whispering dialogue and deafening explosions with simple presets, or fully customize the audio to your liking.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,6 +54,16 @@ The easiest and safest way to install KoalaSync is directly through the official
|
||||
2. **Invite Friends:** Share the auto-copied invite link. Once they click it, they automatically join.
|
||||
3. **Pick a Video:** Navigate to the Sync Tab, select the tab playing your video, and grab some popcorn! 🍿
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Localization & Translations
|
||||
|
||||
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
|
||||
- **Available Languages**: Support is included for 15 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), Chinese (Simplified) (`zh`), Ukrainian (`uk`), and European Portuguese (`pt`).
|
||||
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
|
||||
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](docs/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 🛠️ For Developers & Self-Hosters
|
||||
@@ -70,7 +81,7 @@ The easiest and safest way to install KoalaSync is directly through the official
|
||||
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.
|
||||
|
||||
@@ -81,44 +92,54 @@ Deploy your own private relay server using our official image:
|
||||
docker pull ghcr.io/shik3i/koalasync:latest
|
||||
|
||||
# Or use our example compose file
|
||||
cp docker-compose.caddy.example.yml docker-compose.yml
|
||||
cp examples/docker-compose.caddy.example.yml docker-compose.yml
|
||||
docker-compose up -d
|
||||
```
|
||||
The server will be available at `ws://localhost:3000`. See [Docker network compose](docker-compose.caddy.example.yml) or [Static IP compose](docker-compose.ip.example.yml) for ready-to-use Docker Compose files.
|
||||
The server will be available at `ws://localhost:3000`. See [Docker network compose](examples/docker-compose.caddy.example.yml) or [Static IP compose](examples/docker-compose.ip.example.yml) for ready-to-use Docker Compose files.
|
||||
|
||||
To connect your extension to a self-hosted server, open the popup → **Room** tab → select **Custom Server** → enter your server's WebSocket URL (e.g., `ws://localhost:3000`).
|
||||
|
||||
> **⚠️ Note**: `ws://` only works for `localhost`. If you deploy to a real domain, you **must** use `wss://` (e.g., `wss://sync.yourdomain.com`). This requires a TLS-terminating reverse proxy (e.g., Caddy, Nginx, or Traefik) in front of the relay server. See [Caddyfile.example](Caddyfile.example) for a production-ready template.
|
||||
> **⚠️ Note**: `ws://` only works for `localhost`. If you deploy to a real domain, you **must** use `wss://` (e.g., `wss://sync.yourdomain.com`). This requires a TLS-terminating reverse proxy (e.g., Caddy, Nginx, or Traefik) in front of the relay server. See [Caddyfile.example](examples/Caddyfile.example) for a production-ready template.
|
||||
|
||||
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.2+)
|
||||
|
||||
### 🌐 Localization & Translations
|
||||
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.
|
||||
|
||||
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
|
||||
- **Available Languages**: Support is included for 6 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), and Russian (`ru`).
|
||||
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
|
||||
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](website/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
|
||||
**Verify a Docker image:**
|
||||
```bash
|
||||
gh attestation verify oci://ghcr.io/shik3i/koalasync:latest \
|
||||
-R Shik3i/KoalaSync
|
||||
```
|
||||
|
||||
**Verify an extension binary:**
|
||||
```bash
|
||||
gh attestation verify dist/koalasync-chrome.zip \
|
||||
-R Shik3i/KoalaSync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 📖 Documentation & Links
|
||||
|
||||
- **[CHANGELOG.md](CHANGELOG.md)**: Full version history for the extension and relay server.
|
||||
- **[TESTED_SERVICES.md](TESTED_SERVICES.md)**: Detailed compatibility matrix of tested streaming platforms and known limitations.
|
||||
- **[TRANSLATION.md](website/TRANSLATION.md)**: Translation and localization guide for contributors.
|
||||
- **[PRIVACY.md](PRIVACY.md)**: Data Handling and Privacy Policy.
|
||||
- **[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](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.
|
||||
- **[ROADMAP.md](docs/ROADMAP.md)**: Planned features, backlog, and rejected ideas.
|
||||
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
|
||||
- **[Caddyfile.example](Caddyfile.example)**: Production Caddy configuration for website and relay.
|
||||
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,57 @@
|
||||
KoalaSync: Private Watch Parties for Emby, Jellyfin, Plex, Netflix & YouTube
|
||||
|
||||
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube — KoalaSync is designed for smooth, browser-based watch parties.
|
||||
|
||||
|
||||
✨ CORE FEATURES
|
||||
No account required. No tracking. Just create a room, invite your friends, and start watching together.
|
||||
|
||||
• Real-Time Video Sync: Play, pause, seek, and watch together with fast synchronized playback across everyone in your room.
|
||||
• No Account Needed: Create a room and share the invite link. No emails, no passwords, no sign-ups. Pick a nickname or let KoalaSync generate one for you.
|
||||
• Works Almost Everywhere: If the website uses a standard HTML5 video player, KoalaSync can usually sync it. Perfect for streaming sites, self-hosted media servers, and other websites.
|
||||
• Smart Binge-Watching: When a new episode loads, KoalaSync automatically pauses the lobby until everyone is ready. No spoilers, no one left behind.
|
||||
• Smart Audio Compressor: Tired of quiet dialogue and suddenly loud action scenes? Balance whispering, explosions, and music with a single click while you watch.
|
||||
• One-Click Invites: Send a smart invite link to your friends. When they open it, KoalaSync automatically configures the room so they can join instantly.
|
||||
• 13 Languages: Enjoy a native experience with a fully translated user interface.
|
||||
|
||||
|
||||
|
||||
🛡️ PRIVACY & SECURITY
|
||||
KoalaSync is built for private watch parties without unnecessary data collection.
|
||||
|
||||
• No Tracking: Zero analytics, zero telemetry, and absolutely no behavior profiling.
|
||||
• Anonymous by Design: No accounts needed. Rooms can be joined with a simple nickname.
|
||||
• Ready Out of the Box: Install KoalaSync and start watching immediately using the official public relay server. No technical setup required.
|
||||
• RAM-Only Public Server: The official relay server operates entirely in volatile RAM. No databases, no stored watch history, no persistent room data. Room data exists only temporarily and disappears when the room closes.
|
||||
• Self-Hostable: Want full control? You can run your own private KoalaSync relay server via Docker in seconds. Self-hosting is optional and never required.
|
||||
|
||||
|
||||
|
||||
🚀 HOW IT WORKS
|
||||
1. Install KoalaSync.
|
||||
2. Click "Create Room" to start a private watch party.
|
||||
3. Share the invite link with your friends.
|
||||
4. Open your favorite streaming site or media server.
|
||||
5. Select the active video tab.
|
||||
6. Press play — everyone stays perfectly in sync.
|
||||
|
||||
|
||||
|
||||
⚙️ UNDER THE HOOD
|
||||
KoalaSync is lightweight, transparent, and built with privacy in mind.
|
||||
|
||||
• On-Demand Relay: Playback state is synchronized through a custom WebSocket-based relay server. No persistent connection — the relay is only active while you're in a room. No background traffic, no idle connections.
|
||||
• 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.
|
||||
• Open Architecture: The project is designed to be inspectable, forkable, and easy to review.
|
||||
|
||||
|
||||
|
||||
💻 OPEN SOURCE
|
||||
KoalaSync was built by a solo developer who needed a fast, secure way to watch movies with friends. The code is fully transparent under the MIT license: audit it, fork it, improve it, or self-host your own relay server.
|
||||
|
||||
Found a bug or have a feature idea? Open an issue on GitHub. Contributions and code reviews are always welcome.
|
||||
|
||||
• Website: https://sync.koalastuff.net
|
||||
• GitHub: https://github.com/Shik3i/KoalaSync
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
@@ -21,27 +21,27 @@ 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.
|
||||
|
||||
## 3. Mandatory Reading
|
||||
Before touching any code, you MUST read the following documents in order:
|
||||
1. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) – Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
|
||||
2. [extension/README.md](extension/README.md) – Extension components, tab structure, and loading process.
|
||||
3. [docs/SYNC_GUIDE.md](docs/SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||
1. [ARCHITECTURE.md](ARCHITECTURE.md) – Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
|
||||
2. [extension/README.md](../extension/README.md) – Extension components, tab structure, and loading process.
|
||||
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||
|
||||
## 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
|
||||
@@ -100,6 +100,15 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
|
||||
## 10. Common Workflows
|
||||
|
||||
## CRITICAL: Git & Release Rules
|
||||
|
||||
- **NEVER** push, commit, tag, or release without explicit user instruction.
|
||||
- **NEVER** retag or force-push without explicit instruction.
|
||||
- **NEVER** create tags for documentation-only or README changes.
|
||||
- **NEVER** run `git push`, `git tag`, `git commit` unless the user says "push", "commit", or "tag".
|
||||
- Only the user decides when to commit, push, tag, or release.
|
||||
- Ask before any git write operation.
|
||||
|
||||
### ⚠️ Pre-Session Git Sync (MANDATORY)
|
||||
Before starting any task, committing, or pushing, you **MUST** run `git pull --rebase` to ensure your local branch is up-to-date with `origin/main`. CI pipelines and other agents may push commits concurrently. Skipping this step will cause merge conflicts and rejected pushes.
|
||||
|
||||
@@ -128,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.
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
- **Extension: Auto-Switch to Sync Tab**: The UI now intelligently switches to the Sync tab when you join a room to guide video selection.
|
||||
- **Extension: Uninstall URL Integration**: Prepared an uninstall URL setup that works natively across Chrome and Firefox, cleanly attaching browser context for analytics.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Infinite Seek Loop Prevention**: Replaced the fragile time-based seek suppression with an exact target-time verification mechanism, entirely eliminating infinite seek loops on slow buffers.
|
||||
- **Extension: Zombie Connections Resolved**: Implemented a forced disconnect upon ping timeouts, ensuring the extension reliably auto-reconnects when the WebSocket hangs in a half-open state.
|
||||
- **Extension: Room Switching Architecture**: Joining a new room while already connected now explicitly severs the old connection first, preventing state cross-contamination.
|
||||
- **Extension: Join/Leave Race Conditions**: Added UI locks to prevent users from accidentally sending conflicting connection commands via rapid double-clicking.
|
||||
- **Extension: Same-Room Invite Bypass**: Clicking an invite link for the room you are currently in no longer triggers a redundant reconnect, instead instantly confirming the join.
|
||||
- **Extension: Audio settings now propagate immediately to video tabs**: Changes made in the audio options page are now instantly applied to the active video tab. Previously, settings saved to `chrome.storage.local` were not picked up by the background listener, which only watched `chrome.storage.sync`.
|
||||
- **Extension: Audio compressor now logs enable/disable state and resume failures**: The compressor reports when it is activated or bypassed, and warns if the `AudioContext` cannot be resumed (e.g. browser autoplay policy requires a user gesture on the page first).
|
||||
- **Extension: Video heartbeat no longer sent when alone in a room**: The full media metadata `PEER_STATUS` is now only emitted when other peers are present. The session keepalive (background heartbeat) continues to run unaffected, preventing the server reaper from disconnecting idle peers.
|
||||
- **Server: Increased `failedAuthAttempts` eviction threshold from 50k to 200k**: Reduces frequency of expensive batch evictions under high auth-failure volumes, smoothing heap usage.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.4] — 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Extension: Error notifications now respect `browserNotifications` setting**: Server error events (e.g. "Server is restarting") no longer trigger a browser notification when the user has disabled notifications in the extension settings.
|
||||
- **Server: Misleading reconnect message corrected**: The graceful shutdown message no longer tells users to manually reconnect — the extension handles this automatically.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.3] — 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Artifact Attestations (Supply Chain Security)**: All release artifacts (Docker images, extension ZIPs) are now published with signed [SLSA provenance attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) via `actions/attest@v4`. Anyone can verify that an artifact was built from this repository using `gh attestation verify`.
|
||||
- **Admin health: `rateLimits.denied` counters**: New rolling counters track actual rate-limit denials (429 responses), separate from `rateLimits.trackedClients` which reports unique IPs in the tracking window.
|
||||
- **Docker HEALTHCHECK**: Container health is now checked every 30s via `GET /health`.
|
||||
- **`npm start` script**: Server can now be started with `npm start`.
|
||||
|
||||
### Fixed
|
||||
- **Server: `activeLobby` no longer silently overwritten**: If a second peer sends `EPISODE_LOBBY` while a lobby is already active, the request is now ignored instead of destroying the first peer's lobby.
|
||||
- **CORS log sanitization**: Rejected origin headers are sanitized (`\r\n` stripped) to prevent log injection.
|
||||
- **Extension: pagehide resource leak**: `keepAlivePort`, `lobbyPollTimer`, heartbeats, and `MutationObserver` are now properly cleaned up when a tab is hidden or enters bfcache.
|
||||
- **Extension: unhandled storage rejections**: `chrome.storage.session.set()` calls in the disconnect handler now have `.catch(() => {})`.
|
||||
- **`'Pixel'` duplicate in name generator**: Second occurrence replaced with `'Nitro'` for better name diversity.
|
||||
- **`'opposum'` typo**: Corrected to `'opossum'` in the emoji map and added `'Opossum'` to `USERNAME_NOUNS`.
|
||||
- **Test reliability**: `test-server-routes.mjs` now sets `ADMIN_METRICS_TOKEN` before importing the server module, fixing standalone test execution.
|
||||
- **`MAX_PEERS_PER_ROOM` konsistent**: `.env` auf 25 gesetzt (wie `.env.example`).
|
||||
- **pt-BR.json duplicate removed**: Duplicate `FOOTER_DISCLAIMER` key removed from website locale.
|
||||
|
||||
### Changed
|
||||
- **Admin health: `rateLimitEntries` renamed to `rateLimits.trackedClients`**: The field now accurately describes that it tracks unique clients in the rate-limit window, not denial counts. Update your json_exporter/Grafana config accordingly.
|
||||
- **README restructured**: Sections reordered by progressive technical depth. New "Supply Chain Security" subsection under "For Developers & Self-Hosters" with verification commands.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.2] — 2026-06-09
|
||||
|
||||
### Added
|
||||
- **Chrome Web Store i18n Support**: Added `default_locale: "en"` to manifest and created `_locales/*/messages.json` for all 13 supported languages. This unlocks the language selection dropdown in the Chrome Web Store dashboard, allowing translated store listings (title, description) per locale. The extension's own UI translations (`locales/*.json` + `i18n.js`) remain unchanged.
|
||||
- **Locale test coverage**: Extended `scripts/test-locales.js` to validate all `_locales/*/messages.json` files (correct format, required keys, no duplicates) and verify `default_locale` is set in the manifest.
|
||||
|
||||
### Fixed
|
||||
- **Copy Logs button alignment**: Removed stray `margin-top: 8px` inherited from `.secondary` class that pushed the button 8px down in the connection status row.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.1] — 2026-06-09
|
||||
|
||||
### Added
|
||||
- **Server Ping Display**: Measures round-trip latency to the relay server via application-level ping/pong events. The extension sends `PING { t }` every 15 seconds; the server responds with `PONG { t }`. Round-trip time is calculated client-side and displayed in the Status tab, color-coded (<50ms green, 50–150ms yellow, >150ms red). No ping value is shown when disconnected or if the server does not respond within 5 seconds.
|
||||
- **Peer Ping Response (Future-Proof)**: The extension can now respond to incoming `PING { t, sender }` events from other peers by sending back `PONG { t, target: sender }`. The relay server forwards `PING` to the target peer and routes `PONG` back to the original sender. Both client and server validate that peers are in the same room before forwarding/routing. Peer-to-peer ping initiation will be activated in a future extension update without requiring a server restart.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.0] — 2026-06-08
|
||||
|
||||
### Added
|
||||
- **Web Audio API Compressor**: Built-in audio dynamic range compression with four presets (Recommended, Dynamic Range, Vocal Enhancement, Smooth) and fully customizable sliders (threshold, ratio, knee, attack, release). Uses dry/wet crossfade (40ms linear ramp) to avoid clicks. Configured via the new Audio Options page accessible from the Settings tab.
|
||||
- **Audio Options Page** (`audio-options.html`): Dedicated settings page with master toggle, compressor preset selector, real-time custom sliders, and equalizer placeholder. Dark theme matching the popup design.
|
||||
- **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
|
||||
- **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".
|
||||
- **Korean locale**: Fixed broken character in `HOWTO_STEP_2_TEXT` (`클rip보드` → `클립보드`).
|
||||
- **Website COMP_FEAT_6_KOALA**: Normalized from inconsistent "6 Languages" to "13 Languages" across all locale files (en, de, es, fr, pt-BR, ru).
|
||||
- **Debug report showing wrong logs**: Fixed `logs.slice(-50)` and `history.slice(-20)` in the "Copy Debug Report" feature. Since `addLog()` and `addToHistory()` use `unshift` (inserting entries at index 0), the arrays are ordered newest-first. `slice(-N)` took the N **oldest** entries instead of the N **newest**. Changed to `slice(0, N).reverse()` to correctly include the most recent logs and display them chronologically.
|
||||
|
||||
---
|
||||
|
||||
## [v2.1.2] — 2026-06-06
|
||||
|
||||
### Fixed
|
||||
- **Episode guard regex**: Fixed `isDifferentEpisode()` not detecting episode changes when the MediaSession title uses `Sxx:Exx` format (colon separator, as used by Jellyfin/Emby). The regex character class `[\s\-\.]` was replaced with `[^a-zA-Z0-9]` to match **any** non-alphanumeric separator between season and episode numbers, preventing play/pause/seek commands from a different episode leaking through and incorrectly manipulating a peer's playback.
|
||||
- **Per-device storage isolation**: Migrated `username`, `roomId`, `password`, `serverUrl`, and `useCustomServer` from `chrome.storage.sync` (synced across Google account) to `chrome.storage.local` (per-device). This prevents the extension from automatically joining the same room with the same name on multiple devices. Existing user data is migrated silently on first run; all preferences (`filterNoise`, `autoSyncNextEpisode`, etc.) remain synced.
|
||||
|
||||
### Changed
|
||||
- Added one-time migration fallback in `getSettings()` and popup `init()` to copy existing user settings from `storage.sync` to `storage.local` on first launch after the update.
|
||||
|
||||
---
|
||||
|
||||
## [v2.1.0] — 2026-06-04
|
||||
|
||||
### Added
|
||||
- Added full translation support for 7 new languages to both the browser extension popup settings and landing website: Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`).
|
||||
- Implemented robust, centralized browser system language detection mapping `pt-BR` to Brazilian Portuguese and other `pt` locales (like `pt-PT`) automatically to European Portuguese.
|
||||
- Added flag emojis to language selector dropdowns in both the extension popup and landing/utility web pages for quicker visual identification.
|
||||
- Added 181 translation keys parity validation suite checks for the new languages.
|
||||
|
||||
### Fixed & Hardened (Extension Audit)
|
||||
- Guarded all website `localStorage` interactions to prevent initialization/join flow script failures on privacy-hardened or cookie-blocked browser configurations.
|
||||
- Added robust validation null-guards to `chrome.runtime.onMessage` listeners across all extension scripts (`bridge.js`, `content.js`, `background.js`, `popup.js`) to reject unexpected runtime messages.
|
||||
- Guarded CustomEvent payload destructuring in `bridge.js` to ensure stability when receiving third-party page events.
|
||||
- Wrapped `video.currentTime` seeking adjustments during forced sync in content scripts with exception handling to absorb uninitialized video state DOMExceptions.
|
||||
- Added payload validation guards on incoming Socket.IO events within the background script's event handlers to secure against malformed server updates.
|
||||
- Prevented noisy browser console exceptions from context invalidation in target tabs by catching promise rejections on extension message dispatches.
|
||||
|
||||
### Performance
|
||||
- Implemented in-memory language dictionary caching in the background script to completely avoid redundant extension package filesystem reads during translations.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.8] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- Fixed a bug where switching language inside the extension popup overwrote dynamic fields (such as active room ID, connection status, active server details, and video debug info) with default localized placeholder texts.
|
||||
- Fixed a version reporting mismatch where the copied logs (debug reports) and connection handshake parameters incorrectly reported the hardcoded `1.9.0` version instead of the actual installed manifest version.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.7] — 2026-06-03
|
||||
|
||||
### Added
|
||||
- Added a `DEBUG_LOGGING` environment variable to the relay server (defaulting to `"0"` / disabled) to prevent console spam from verbose connection (`CONN`), room activity (`ROOM`, `DEDUPE`), and `CORS` events under load. Critical logs like `SERVER`, `SECURITY`, `AUTH`, and `ERROR` remain enabled at all times.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.6] — 2026-06-03
|
||||
|
||||
### Performance & Security Hardening
|
||||
- Optimized failed authentication attempts cache eviction algorithm to $O(1)$ by exploiting Javascript `Map` insertion-order properties. This completely removes the previous array copying and sorting bottleneck, neutralizing a potential main-thread blocking DoS vector under heavy brute-force password traffic.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.5] — 2026-06-03
|
||||
|
||||
### Security & Hardening
|
||||
- Hardened extension room idle auto-leave detection to correctly recognize when the target tab's video heartbeat goes stale (e.g., after tab navigation or media closure).
|
||||
- Exported cleaner graceful shutdown and lifecycle methods (`stopServerForTests`) from the relay server to prevent socket leaks and port-binding conflicts during verify checks.
|
||||
|
||||
### Added
|
||||
- Added a validation step in `test-locales.js` to ensure the supported language list in `extension/i18n.js` is perfectly synchronized with the actual JSON translation files in the locales directory.
|
||||
- Added a robust route verification test suite (`scripts/test-server-routes.mjs`) covering rate limit throttling, caching headers, and admin metrics access control.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.4] — 2026-06-03
|
||||
|
||||
### Security & Hardening
|
||||
- Hardened relay health endpoints against simple flood traffic: `GET /` and `GET /health` are now limited to 10 requests per minute per client IP.
|
||||
- Added lazy 60-second server-side caching for `GET /`, basic `/health`, and admin `/health` JSON responses to reduce repeated health-check work under noisy polling.
|
||||
- Added stricter brute-force throttling for invalid admin metrics bearer attempts.
|
||||
- Added startup warning for short `ADMIN_METRICS_TOKEN` values and documented that production Node ports must stay private behind Caddy or another trusted reverse proxy.
|
||||
- Lowered the default maximum peers per room to 25.
|
||||
|
||||
### Added
|
||||
- Optional privacy-preserving admin metrics on `/health` when `ADMIN_METRICS_TOKEN` is configured and a valid bearer token is supplied. Metrics are aggregate-only and exclude room IDs, peer IDs, usernames, IP addresses, media titles, passwords, and other user-level data.
|
||||
|
||||
### Changed
|
||||
- Removed `bcryptjs`; temporary room passwords continue to use keyed SHA-256/HMAC hashing as documented.
|
||||
- Public room discovery is now rate-limited server-side to one refresh every 10 seconds per socket, with the extension refresh button locked for 11 seconds.
|
||||
|
||||
### Fixed
|
||||
- Improved Shadow DOM video detection so real embedded players are not hidden by smaller light-DOM preview or placeholder videos.
|
||||
- Fixed join-button timeout cleanup after join status responses.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] — 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
|
||||
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] — 2026-06-01
|
||||
|
||||
### Fixed
|
||||
- Video detection on Prime Video: `findVideo()` now scores all video elements by size, duration, and mute state instead of picking the first one. Fixes 0×0 placeholder being selected over the actual player.
|
||||
- History entries in debug report showing `?` instead of action names.
|
||||
- Prime Video status in compatibility matrix updated to reflect partial support.
|
||||
|
||||
### Added
|
||||
- Multi-video overview table in Copy Debug Report when a page has more than one `<video>` element. Shows resolution, mute state, playback state, readyState, duration, and marks the currently targeted video.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.0] — 2026-06-01
|
||||
|
||||
### 🌍 Multi-Language Extension (Biggest Feature!)
|
||||
- **6-Language UI**: The browser extension is now fully translated into **English, German, French, Spanish, Portuguese (Brazilian), and Russian**. Switch languages instantly in Settings without reload.
|
||||
- **Real-Time i18n**: Every label, button, tooltip, toast notification, empty state, and onboarding guide updates dynamically when the language changes.
|
||||
|
||||
### New Features
|
||||
- **Copy Debug Report (Markdown)**: The *Copy Logs* button in the Status tab now copies a fully formatted Markdown debug report — system info, connection status, video diagnostics, action history, and logs. One click, paste into a GitHub issue, all debugging data ready.
|
||||
- **Platform Auto-Detection**: The Dev tab now identifies streaming platforms (YouTube, Netflix, Twitch, Prime Video, Disney+, HBO Max, Vimeo, Dailymotion) and displays the detected platform.
|
||||
- **Enhanced Video Debug Info**: 20+ new fields in the Status tab including network state, buffered ranges, dimensions (with 0×0 warning), media error codes, shadow DOM status, seeking/ended/loop flags, volume, playback speed, and data attributes.
|
||||
- **No-Video Diagnostic Mode**: When no video is found, the Status tab shows platform, page title, video count, shadow DOM presence, and MediaSession data to help troubleshoot.
|
||||
|
||||
### Changed
|
||||
- **New TwoPointZero Branding**: Updated extension icons (16/32/48/96/128px).
|
||||
- **Larger Popup Logo**: Extension popup icon increased to 48px.
|
||||
- **Prime Video Unblocked**: Removed `amazon.` from the tab blacklist so Amazon/Prime Video tabs appear in the video selector.
|
||||
- **Improved Debug Report**: Full User-Agent string for accurate browser identification, UTC timestamp, connection details including server URL and room info.
|
||||
- **Smart Disconnect**: Improved disconnect handling when leaving rooms.
|
||||
- **Human-Readable Room IDs**: Expanded word lists for friendlier room names.
|
||||
- **Custom Server Support**: WEB_JOIN_REQUEST and join button for custom server invite flows.
|
||||
- **Reconnection Strategy**: Custom server reconnection improvements.
|
||||
- **Episode-Aware Sync**: Command sequencing with smarter episode transition detection and echo suppression for smoother series binges.
|
||||
- **Sync Status Refinements**: YouTube and Twitch sync behavior improved.
|
||||
- **No External Dependencies**: Extension remains dependency-free with no library overhead.
|
||||
|
||||
### Fixed
|
||||
- Hardcoded strings, missing translation keys, and Service Worker notification race conditions.
|
||||
|
||||
---
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
- **MAJOR** (x.0.0): Breaking protocol changes, architecture rewrites, or major feature milestones.
|
||||
- **MINOR** (0.x.0): New features, significant enhancements, new translations, or UI redesigns.
|
||||
- **PATCH** (0.0.x): Bug fixes, minor improvements, and documentation updates. PATCH releases may not receive individual changelog entries if bundled with a MINOR release.
|
||||
@@ -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,7 +155,7 @@ 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) |
|
||||
| **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`, `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").
|
||||
|
||||
@@ -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,6 +2,8 @@
|
||||
|
||||
**KoalaSync does not collect, store, or sell any personal data.**
|
||||
|
||||
*We don't track you. We only track our server* (relying exclusively on aggregated, anonymous, and non-personal system metrics to monitor performance and stability).
|
||||
|
||||
KoalaSync is designed with a **Security-First & Volatile** architecture. This means we prioritize keeping your data out of persistent storage, though certain technical data must be processed temporarily to ensure service stability and security.
|
||||
|
||||
## 1. Data Processing (In-Memory Only)
|
||||
@@ -31,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,22 +1,82 @@
|
||||
# KoalaSync Roadmap
|
||||
|
||||
Dieses Dokument erfasst zukünftige technische Pläne und Optimierungen für das KoalaSync-System.
|
||||
> Feature priorities, planned work, backlog, and rejected ideas for KoalaSync.
|
||||
|
||||
---
|
||||
|
||||
## Geplante Optimierungen & Technische Roadmap
|
||||
## Status Legend
|
||||
|
||||
| Badge | Meaning |
|
||||
|---|---|
|
||||
| 🚧 In Progress | Currently being developed |
|
||||
| 📋 Planned | Prioritized for an upcoming phase |
|
||||
| 💡 Backlog | Under evaluation, not yet prioritized |
|
||||
| ❌ Rejected | Declined (with rationale) |
|
||||
| ✅ Completed | Shipped |
|
||||
|
||||
---
|
||||
|
||||
## 🚧 In Progress
|
||||
|
||||
*Currently being worked on.*
|
||||
|
||||
| Feature | Priority | Area |
|
||||
|---|---|---|
|
||||
| *(none yet)* | | |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Planned
|
||||
|
||||
*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
|
||||
- **Category:** UX / Ease of Sharing
|
||||
- **Background:** The invite link currently only contains the room ID. The invited person has to manually open the page. Ideally, the link would include the shared tab's URL so the invitee gets redirected to the right page and the tab is auto-selected (auto-matching via tab title already exists).
|
||||
- **Known challenges:**
|
||||
- Many streaming sites (e.g., Emby, Jellyfin) don't have unique URLs per content — once inside the player, the URL stays the same.
|
||||
- Dozens of such edge cases exist; a generic solution is difficult.
|
||||
- Would likely need site-specific extractor logic (similar to the existing sync service adapters).
|
||||
- **Possible approaches:**
|
||||
- Fallback: if no unique URL can be determined, only pass the tab title.
|
||||
- Site-specific URL extraction for known services.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Backlog
|
||||
|
||||
*Ideas and feature requests under evaluation.*
|
||||
|
||||
### In-room chat overlay (like TeleParty)
|
||||
|
||||
- **Priority:** P3
|
||||
- **Category:** Social / Communication
|
||||
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
|
||||
- **Why backlog (still uncertain):**
|
||||
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
|
||||
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
|
||||
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
|
||||
- **Status:** Under evaluation, may come later.
|
||||
|
||||
---
|
||||
|
||||
## ❌ Rejected
|
||||
|
||||
*Declined features with rationale — keeps decisions documented so they don't get re-debated.*
|
||||
|
||||
| Feature | Reason |
|
||||
|---|---|
|
||||
| *(none yet)* | |
|
||||
|
||||
### 1. Behebung des synchronen Sortierungs-Flaschenhalses bei Auth-Failure LRU-Eviction
|
||||
* **Kategorie**: Performance / DoS-Prävention
|
||||
* **Hintergrund**: Der Server schützt Räume vor Brute-Force-Angriffen durch die Nachverfolgung fehlerhafter Anmeldeversuche (`failedAuthAttempts` Map). Bei Erreichen des Limits von 50.000 Einträgen wird ein Bereinigungsverfahren gestartet, das die gesamte Map in ein Array konvertiert und dieses per `Array.from().sort()` sortiert.
|
||||
* **Problem**: Dies blockiert den Node.js-Main-Thread für mehrere Millisekunden und stellt einen potenziellen Denial-of-Service-Vektor (DoS) dar, wenn ein Angreifer gezielt Fehlversuche spamt.
|
||||
* **Geplante Lösung**:
|
||||
- Umstellung auf eine echte, $O(1)$-basierte LRU-Cache-Datenstruktur (z. B. doppelt verkettete Liste in Kombination mit einer Map).
|
||||
- Alternativ: Ein vereinfachtes zeitbasiertes Ablauf-Verfahren oder ein schrittweises Löschen von Segmenten (Chunk-Eviction), um Blockaden des Main-Threads vollständig auszuschließen.
|
||||
|
||||
### 2. Aufteilung großer JavaScript-Dateien (> 800 Zeilen) in kleinere Module
|
||||
* **Kategorie**: Wartbarkeit / AI-Kontext-Optimierung
|
||||
* **Hintergrund**: Einige Kern-Dateien wie `background.js` und `popup.js` sind stark angewachsen und überschreiten 800 Zeilen. Dies erschwert das manuelle Debugging und verbraucht unnötig viel Kontextfenster bei AI-Modellen.
|
||||
* **Geplante Lösung**:
|
||||
- Strukturierte Aufteilung der Logik in separate, fokussierte Module (z. B. UI-Renderer, Message-Router, Storage-Manager, Socket-Client).
|
||||
- Nutzung von ES-Modulen zur sauberen Strukturierung und besseren Wiederverwendbarkeit.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -4,19 +4,19 @@ This document tracks which streaming platforms and media servers have been teste
|
||||
|
||||
| Service | Sync Works | Media Title | Episode Auto-Sync | Notes |
|
||||
|---------|:----------:|:-----------:|:-----------------:|-------|
|
||||
| **YouTube** | ✅ Full | ✅ Full | ❌ | Best-in-class support. Native player API, reliable title detection. |
|
||||
| **Twitch** | ✅ Full | ✅ Full | ❌ | Live-only platform. Tested on regular streams. |
|
||||
| **Netflix** | ✅ Full | ⚠️ Hidden | ⚠️ Manual | Sync works perfectly, but DRM prevents media title detection. Episode transitions may require manual lobby. |
|
||||
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
|
||||
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
|
||||
| **Plex** | Not tested | Not tested | Not tested | Community reports indicate compatibility via HTML5 player mode. |
|
||||
| **Disney+** | Not tested | Not tested | Not tested | Widevine DRM may restrict title detection similar to Netflix. |
|
||||
| **Prime Video** | ⚠️ Partial | ⚠️ Partial | ❌ | Video elements detected (2 on page, picks larger one). Playback state + time readable. However, the preview/trailer video may be selected instead of the main content. Play/Pause commands may not reach the correct player. Title detection from MediaSession API may work for some content. |
|
||||
| **YouTube** | ✅ Full | ✅ Full | ❌ | Individual videos, not episodes — no episode auto-sync. |
|
||||
| **Twitch** | ✅ Full | ✅ Full | ❌ | Individual streams/VODs, not episodes — no episode auto-sync. |
|
||||
| **Netflix** | ✅ Full | ❌ | ❌ | No media title exposed. |
|
||||
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Best-in-class support. |
|
||||
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | — |
|
||||
| **Plex** | Not tested | Not tested | Not tested | — |
|
||||
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | Series title only (e.g. "The Simpsons"), no episode info. |
|
||||
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — |
|
||||
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
|
||||
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
|
||||
| **Vimeo** | Not tested | Not tested | Not tested | — |
|
||||
| **Dailymotion** | Not tested | Not tested | Not tested | — |
|
||||
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | German public broadcasters. HTML5 players expected to work. |
|
||||
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | — |
|
||||
|
||||
## Legend
|
||||
|
||||
@@ -39,7 +39,7 @@ Tested a service that's not listed? Found different behavior than documented?
|
||||
KoalaSync works on any website with a **standard HTML5 `<video>` element** that allows script injection.
|
||||
|
||||
Limited functionality on certain platforms is typically caused by:
|
||||
- **DRM/Copy Protection** (e.g., Widevine on Netflix, Disney+) which restricts access to media metadata like title and playback state
|
||||
- **DRM/Copy Protection** (e.g., Widevine on Netflix) which restricts access to media metadata like title and playback state
|
||||
- **Shadow DOM encapsulation** that hides video elements from content scripts
|
||||
- **Strict Content Security Policies** (CSP) that block script injection
|
||||
|
||||
@@ -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,118 @@
|
||||
# Co-Host (Multi-Controller) — Implementation Plan
|
||||
|
||||
Branch base: `feature/host-control-mode` (builds directly on it).
|
||||
Goal: let the room owner grant **playback control to several peers** (co-hosts), not
|
||||
just one — e.g. 4 of N people in a room may drive play/pause/seek, the rest are guests.
|
||||
|
||||
This is the second server-gated feature the `capabilities` hook was designed for
|
||||
(`CAPABILITIES.CO_HOST = 'co-host'`, already stubbed in `shared/constants.js`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Roles
|
||||
|
||||
| Role | Can drive (play/pause/seek/force-sync/episode-lobby) | Can promote/demote + toggle mode |
|
||||
|------|------|------|
|
||||
| **Owner** (room creator, = today's "host") | yes (always a controller) | **yes** |
|
||||
| **Controller** (co-host) | yes (in `host-only` mode) | no |
|
||||
| **Guest** | only in `everyone` mode | no |
|
||||
|
||||
The single-host feature is just the special case `controllers = { owner }`.
|
||||
|
||||
## 2. Data model
|
||||
|
||||
### Server (`room` object)
|
||||
- `ownerPeerId` — the creator / manager. Keep `hostPeerId` as an **alias** (= ownerPeerId)
|
||||
so older clients keep working.
|
||||
- `controllers: Set<peerId>` — peers allowed to drive. **Always contains ownerPeerId.**
|
||||
- `controlMode: 'everyone' | 'host-only'` — unchanged wire values (`'host-only'` now means
|
||||
"restricted to controllers", not "single host").
|
||||
- `MAX_CONTROLLERS` cap (e.g. 10) to bound the set + payload.
|
||||
|
||||
### Shared constants
|
||||
- `CAPABILITIES.CO_HOST = 'co-host'` (un-stub it) → add to `SERVER_CAPABILITIES`.
|
||||
- New events:
|
||||
- `SET_PEER_ROLE` (client→server): `{ peerId, controller: boolean }` — owner promotes/demotes.
|
||||
- Extend `CONTROL_MODE` (server→client) payload: `{ controlMode, ownerPeerId, hostPeerId, controllers: [peerId...] }`.
|
||||
- `ROOM_DATA` gains `ownerPeerId` + `controllers`.
|
||||
|
||||
## 3. Gate generalization (the core change)
|
||||
|
||||
Today the gate compares against a single `hostPeerId`. Generalize to set membership:
|
||||
|
||||
- **Server relay gate** (`server/index.js`): `controlMode === 'host-only' && !room.controllers.has(mapping.peerId)` → drop. (Was `mapping.peerId !== room.hostPeerId`.)
|
||||
- **Background gates** (sender + receiver): replace `amHost()` / `senderId !== hostPeerId`
|
||||
with controller-set membership: `controllers.includes(myPeerId)` / `senderId ∈ controllers`.
|
||||
- **Helpers:** split `amHost()` into `amOwner()` (manage rights) and `amController()`
|
||||
(drive rights). The desync/snap-back path keys on `!amController()` instead of `!amHost()`.
|
||||
|
||||
`SET_PEER_ROLE` handler (server): validate sender is owner, target is a current peer in the
|
||||
room, enforce `MAX_CONTROLLERS`, always keep owner in the set, then broadcast `CONTROL_MODE`
|
||||
with the new `controllers`.
|
||||
|
||||
## 4. Client + UI
|
||||
|
||||
- **Owner** sees the peer list with a per-peer **"Controller" toggle** (promote/demote) plus
|
||||
the existing mode toggle.
|
||||
- **Controllers** see a "Controller" badge and are NOT locked out of the remote-control buttons.
|
||||
- **Guests** see "Guest" + the host-only notice (unchanged).
|
||||
- The promote UI + co-host badges render only when the relay advertises the `co-host`
|
||||
capability (feature detection, same pattern as `hostControlSupported`).
|
||||
- i18n: new keys (`ROLE_CONTROLLER`, `BTN_PROMOTE`, `BTN_DEMOTE`, …) across all locales.
|
||||
|
||||
## 5. Backwards compatibility
|
||||
|
||||
- **New client + old server** (host-control only, no `co-host` capability): no co-host UI;
|
||||
behaves as today's single-host. ✓
|
||||
- **Old client + new server**: ignores `controllers` / `SET_PEER_ROLE`. An old client that
|
||||
the owner promotes still **gates itself** (its sender-gate only knows `!amHost`), so it
|
||||
can't drive — it degrades to a guest. Co-host requires a client that understands
|
||||
`controllers`. Document this; not a crash. ✓
|
||||
- No `PROTOCOL_VERSION` bump needed — purely additive, same as host-control.
|
||||
|
||||
## 6. Edge cases
|
||||
- **Controller leaves** → `removePeerFromRoom` also does `room.controllers.delete(peerId)`.
|
||||
- **Owner leaves** → fallback: promote the earliest remaining **controller** to owner (prefer
|
||||
a controller over a random peer); if none, earliest peer; keep the rest of the set. Reuse
|
||||
the `peerJoinLocks` guard so a reconnect/second-tab doesn't demote (same fix as host).
|
||||
- **Promote a peer not in the room** → server rejects (target must be a live peer).
|
||||
- **Promote beyond `MAX_CONTROLLERS`** → server rejects, re-syncs the owner's UI.
|
||||
- **`everyone` mode** → the `controllers` set is still maintained (so flipping to `host-only`
|
||||
keeps the chosen co-hosts), it just isn't enforced while in `everyone`.
|
||||
- **peerId spoofing** → unchanged accepted limitation (see `docs/KNOWN_LIMITATIONS.md`);
|
||||
co-host doesn't widen it materially (still bounded to a temporary room).
|
||||
|
||||
## 7. Scale: the "4 of 510 people" part — read this
|
||||
|
||||
The role change above is moderate. **Putting 510 people in one room is a separate, larger
|
||||
problem** and should be its own track:
|
||||
|
||||
- `MAX_PEERS_PER_ROOM` is **25** today. 510 needs a large raise + load testing.
|
||||
- **The real bottleneck at scale is heartbeat fan-out, not control events.** Every peer
|
||||
heartbeats and the relay broadcasts each to all peers → O(N²) per interval. At 510 that's
|
||||
~510×509 / 15s ≈ **17k msg/s just for heartbeats** — the scaling wall.
|
||||
- **Co-host actually *helps* the control-event side:** in `host-only` mode only the few
|
||||
controllers emit play/pause/seek, so event *sources* drop from N to K (e.g. 4). Restricting
|
||||
who can drive is synergistic with big rooms.
|
||||
- Large rooms therefore need (independent of co-host):
|
||||
- **Heartbeat fan-out reduction** — e.g. only relay controller/owner heartbeats to everyone,
|
||||
relay guest heartbeats only to the owner/controllers (for the UI), or server-side
|
||||
aggregation into periodic snapshots instead of per-peer relay.
|
||||
- **`ROOM_DATA` payload trimming** — a 510-entry peer list is large; send counts + controller
|
||||
details, lazy-load the full roster.
|
||||
- Possibly the **socket.io Redis adapter** for horizontal scaling, and broadcast tuning.
|
||||
|
||||
## 8. Effort estimate
|
||||
- **Co-host roles** (server gate generalization + `SET_PEER_ROLE` + owner-leave fallback +
|
||||
client gates + promote UI + i18n), at the current ≤25-peer scale: **~3–4 dev days** (same
|
||||
shape as host-control itself — mostly generalizing host→controller-set).
|
||||
- **Large-room scaling (510)**: a **separate ~1–2 week** track (heartbeat redesign + payload
|
||||
trimming + cap raise + load testing), independent of co-host. Recommend shipping co-host at
|
||||
the current cap first, then scaling rooms as its own project.
|
||||
|
||||
## 9. Suggested sequencing
|
||||
1. `CAPABILITIES.CO_HOST` + `controllers`/`ownerPeerId` in room state + `ROOM_DATA` (additive).
|
||||
2. Server `SET_PEER_ROLE` + gate generalization + owner-leave fallback + WS tests.
|
||||
3. Background: controller-set membership in both gates + `amOwner`/`amController`.
|
||||
4. Popup: promote/demote toggles (owner) + Controller badge + i18n.
|
||||
5. (Separate track) large-room scaling.
|
||||
@@ -0,0 +1,283 @@
|
||||
# Host Control Mode — Branch Overview, Goals & Edge Cases
|
||||
|
||||
> **This is the canonical entry-point doc for branch `feature/host-control-mode`.**
|
||||
> If you're an agent or contributor picking this up: read this file first, then the
|
||||
> implementation plan in [`host-control-mode-plan.md`](./host-control-mode-plan.md).
|
||||
> Temporary working doc — delete or fold into permanent docs before merging to `main`.
|
||||
>
|
||||
> Status legend: 🔴 open / unresolved · 🟡 idea, needs testing · 🟢 decided
|
||||
|
||||
---
|
||||
|
||||
## 0. Implementation status
|
||||
|
||||
All five layers are implemented & pushed (server + background + content + popup + i18n).
|
||||
Automated checks green: ESLint, WS integration tests (incl. host-only gate, toggle
|
||||
reject, host-leave fallback), content video-finder, locale consistency (15 langs),
|
||||
full release verification.
|
||||
|
||||
**Still needs real-device testing** — the EC test matrix in §7 (YouTube/Netflix/
|
||||
Twitch/Disney+/Jellyfin): involuntary-pause classification (EC-1/EC-5/EC-8), snap-back
|
||||
reliability and fight-loops (EC-4), and the desync/resync flow across players. The
|
||||
intent classifier (EC-9) and snap-back cooldown are first-pass heuristics tuned by
|
||||
reading the code, not yet by watching them behave on each site.
|
||||
|
||||
Deferred by decision (see §8): host grace period on disconnect (EC-10).
|
||||
|
||||
### Capability detection (forward-compat hook)
|
||||
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`
|
||||
(`SERVER_CAPABILITIES` in server, `CAPABILITIES` in shared/constants). The client
|
||||
enables host-control UI/behavior only when the flag is present, so the feature
|
||||
degrades cleanly on an older relay (absent → off) and old clients ignore the
|
||||
field. This is the extensible hook for the planned **co-host** feature (owner
|
||||
promotes guests to additional controllers): it will add a `'co-host'` capability
|
||||
+ events without a protocol bump or breaking older relays/clients. Add new flags
|
||||
to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land.
|
||||
|
||||
### Pre-test self-audit (fixed)
|
||||
- **Popup remote buttons froze for guests** — in host-only a guest's Play/Pause/SYNC
|
||||
click was gated server-side but the button stuck on "Playing"/disabled with no
|
||||
feedback. Now the remote controls are locked (disabled + tooltip) for guests, with
|
||||
backstop guards in the handlers. (popup.js)
|
||||
- **Desync dialog could break under strict CSP** — it used `innerHTML` with inline
|
||||
`style=""` attributes, which Netflix/YouTube/Disney+ strip via `style-src`. Rebuilt
|
||||
with the DOM API (CSSOM `.style` is CSP-safe) inside a **Shadow DOM** so page CSS
|
||||
can't restyle/hide it. (content.js)
|
||||
- **Live-DVR not detected (EC-15)** — `duration === Infinity` misses Twitch/YouTube
|
||||
live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window
|
||||
heuristic in `hcmIsLive()`. (content.js)
|
||||
|
||||
### Pre-test self-audit
|
||||
- ~~**EC-4/EC-1 snap-back thrash:**~~ FIXED — implemented the **buffer-aware deferred
|
||||
snap-back** (`hcmDeferredSnapBack`): on an involuntary event, if the player isn't
|
||||
ready (`readyState<3` or seeking) we wait (poll, 8s cap) until it can play, then snap
|
||||
ONCE to the host's re-queried position instead of repeatedly fighting the buffer.
|
||||
Done defensively/player-agnostically — we can't enumerate every site, so this is safe
|
||||
whether a player fires `pause()` or only `waiting`. Aborts if the user goes solo or is
|
||||
no longer a gated guest; single pending poll (no stacking).
|
||||
- ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED`
|
||||
as authoritative (adopts host-only/guest role) instead of re-checking local mode,
|
||||
since background only sends it to gated guests.
|
||||
- ~~**Dialog/badge text is English-only**~~ FIXED — background resolves the strings
|
||||
via GET_HCM_STRINGS (it has the i18n loader); content fetches them on init with
|
||||
English fallback. 6 new keys (HCM_DIALOG_*/HCM_BADGE_*) across all 15 locales.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this branch is for
|
||||
|
||||
Origin: a GitHub feature request. When watching with larger groups, anyone can pause
|
||||
or seek and disrupt everyone else. The requester wants the room creator to optionally
|
||||
restrict control to a single **host**, the way Teleparty works — guests who try to
|
||||
pause get asked whether they want to pause *only their own* player (and desync), and
|
||||
otherwise get snapped back to the room's position.
|
||||
|
||||
**Goal:** Add an optional per-room **Host Control Mode**. A room can be switched
|
||||
between:
|
||||
- **`everyone`** (default, current behavior): anyone can play/pause/seek for the room.
|
||||
- **`host-only`**: only the host drives the room. A guest's deliberate play/pause/seek
|
||||
is not broadcast; instead they're snapped back to the room position — unless they
|
||||
explicitly choose to desync (go solo) with a "Resync" escape hatch.
|
||||
|
||||
## 2. Trust model (read this before over-engineering)
|
||||
|
||||
This is **client-side trust, by design**. It's a watch party, not a security boundary.
|
||||
The point is preventing *accidental* and *casual* disruption, not stopping someone
|
||||
determined to patch their own extension. We do **not** add auth, tokens, or
|
||||
cryptographic host identity. `peerId` is unauthenticated and that's fine here.
|
||||
|
||||
(We still gate server-side as the robust chokepoint — see plan — but that's about
|
||||
killing spam reliably, not about defeating a hostile client.)
|
||||
|
||||
## 3. Scope / non-goals
|
||||
|
||||
In scope:
|
||||
- Host designation (first joiner = host), mode toggle, host-only gating of all
|
||||
room-moving events, guest snap-back, deliberate-desync flow + resync, host UI.
|
||||
|
||||
Explicit non-goals (for this branch):
|
||||
- Authenticated / spoof-proof host identity.
|
||||
- Persistent host across server restarts (room state is in-memory).
|
||||
- Syncing around personalized ad breaks.
|
||||
- Host transfer UI (auto-fallback to `everyone` when host leaves; manual transfer
|
||||
is a possible later add).
|
||||
|
||||
## 4. Architecture summary
|
||||
|
||||
Three-layer gate for room-moving events from a non-host in host-only mode
|
||||
(`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`):
|
||||
1. **Server** — doesn't relay them (robust chokepoint, kills spam regardless of client).
|
||||
2. **Sender (guest)** — doesn't emit; shows confirm dialog / disables host-only buttons.
|
||||
3. **Receiver** — drops any that slip through (covers old/buggy/modified clients).
|
||||
|
||||
Snap-back reuses the existing `_setSuppress` mechanism (content.js:442) so applying
|
||||
the room state programmatically doesn't echo back as a new event. Target position is
|
||||
extrapolated from the host's last known state (±1s). Full detail + code hooks in the
|
||||
plan doc.
|
||||
|
||||
---
|
||||
|
||||
## 5. The central challenge
|
||||
|
||||
Everything hard about this feature reduces to **one question** (see EC-9):
|
||||
|
||||
> How do we reliably tell a **deliberate** guest pause/seek from an **involuntary**
|
||||
> player/browser event (buffering, ads, tab throttling, source swaps, DRM hiccups)?
|
||||
|
||||
If we get this wrong, guests get spammed with desync dialogs and snap-back loops for
|
||||
things they never did. The host/role plumbing is the easy part; this classifier is the
|
||||
real work. **Design the intent-classifier before writing the gate.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
### EC-1 🔴 Buffering / loading fires a `pause` event
|
||||
content.js listens to `play`/`pause`/`seeked`/`loadeddata` only (content.js:1000-1003),
|
||||
not `waiting`/`stalled`. Pure HTML5 buffering fires `waiting` → harmless. But custom
|
||||
players (Netflix/YouTube/Twitch/JW) often call `video.pause()` during buffering/ads →
|
||||
real `pause` → guest gate would mis-classify as deliberate. Sub-cases: (a) initial load
|
||||
sits paused, no event, fine; (b) mid-stream stall, player-dependent; (c) seek-induced
|
||||
re-buffering may outlast the suppress window and leak. Mitigation: `isBuffering` flag
|
||||
from `waiting`/`playing`, or grace window; in host-only the guest's own state is
|
||||
irrelevant so just ignore involuntary pauses and let catch-up logic (content.js:489)
|
||||
re-sync them.
|
||||
|
||||
### EC-2 🟢 Force-Sync / Episode-Lobby abuse by guests
|
||||
Guest could seek + spam Force-Sync to drag everyone, or spam Episode-Lobby to pause
|
||||
everyone. Decision: host-only blocks guest *initiation* of `FORCE_SYNC_*` and
|
||||
`EPISODE_LOBBY`; guests may only respond (`FORCE_SYNC_ACK`, `EPISODE_READY`). Guests'
|
||||
legitimate path is the personal "Resync" button.
|
||||
|
||||
### EC-3 🟢 Host leaves the room
|
||||
Fall back to `controlMode = 'everyone'`, broadcast `CONTROL_MODE`. Never a stuck locked
|
||||
room. (Auto-promote next peer deferred.)
|
||||
|
||||
### EC-4 🔴 Snap-back fight loop (pause/play/skip back/pause/play)
|
||||
Mashing controls or a janky player → each snap-back may emit events → ping-pong.
|
||||
Mitigation: cooldown (~600ms) after snap-back; ensure snap-back runs fully under
|
||||
suppress. Also: if target is unreachable (seek past buffered range), retry K times then
|
||||
give up — no infinite loop.
|
||||
|
||||
### EC-5 🔴 Ad breaks (YouTube/Twitch/…)
|
||||
Mid-roll ads pause/swap the media element, differ per peer → desync is unavoidable and
|
||||
must NOT spam the dialog. Probably covered by EC-1 buffering grace; flag for explicit
|
||||
testing.
|
||||
|
||||
### EC-6 🟡 Snap-back target accuracy
|
||||
No continuous room clock; extrapolate from host's `currentTime` + `lastHeartbeat`
|
||||
(±1s, worse if stale). The follow-up host correction must also be suppressed so it
|
||||
doesn't read as guest input.
|
||||
|
||||
### EC-7 🟢 Old / buggy / modified guest client
|
||||
Covered by receiver-side + server-side gates.
|
||||
|
||||
### EC-8 🔴 Tab throttling / background tab
|
||||
Backgrounding throttles timers and may pause media. There's existing
|
||||
`visibilityGraceUntil` handling for seeks (content.js:892). Confirm a
|
||||
background-induced pause isn't treated as deliberate in host-only; reuse the grace flag.
|
||||
|
||||
### EC-9 🔴 What counts as "deliberate" — the central unresolved question
|
||||
Collapses EC-1/EC-5/EC-8. Candidate signals: `readyState`/`networkState`/`video.seeking`
|
||||
at event time; recent `waiting`; recent user gesture (`navigator.userActivation`,
|
||||
keydown/click); visibility/focus. Build one shared **intent-classifier** helper in
|
||||
content.js that all host-only gating flows through.
|
||||
|
||||
### EC-10 🔴 Host brief disconnect / reconnect (network blip)
|
||||
Host's wifi drops for 3s and reconnects. With "host leaves → fallback to everyone",
|
||||
a blip would silently unlock the room and demote the host (peerId persists in
|
||||
chrome.storage so they rejoin with the same id, but the server already cleared
|
||||
`hostPeerId`). Mitigation idea: short **host grace period** (e.g. keep `hostPeerId`
|
||||
reserved for ~30s after disconnect; if the same peerId rejoins, restore host + mode).
|
||||
Needs the server reaper (server:644) and `removePeerFromRoom` (server:168) to cooperate.
|
||||
|
||||
### EC-11 🔴 New guest joins mid-session in host-only mode
|
||||
On join they must (a) immediately sync to the host's current position without the host
|
||||
doing anything, and (b) see they're a guest in the UI. ROOM_DATA already carries peers;
|
||||
add `hostPeerId`/`controlMode` so a fresh client knows its role instantly. Verify the
|
||||
existing "newcomer syncs without waiting" path (content.js:542) still fires.
|
||||
|
||||
### EC-12 🔴 Desync semantics — what does "solo" actually mean?
|
||||
When a guest chooses "pause only me", do they (a) fully ignore all subsequent host
|
||||
events until they Resync, or (b) keep receiving but not auto-applying? Define clearly.
|
||||
Proposed: full solo — ignore host play/pause/seek while desynced; Resync re-attaches and
|
||||
snaps to current host position. Also: what state does Resync land them in if the host is
|
||||
currently paused vs playing?
|
||||
|
||||
### EC-13 🔴 Race: host flips to host-only exactly as a guest pauses
|
||||
Event ordering between `SET_CONTROL_MODE`/`CONTROL_MODE` and an in-flight guest `PAUSE`.
|
||||
The `seq` ordering helps, but define the tie-break. Likely: server is authoritative —
|
||||
once it has `host-only`, it drops the guest event regardless of client-side timing.
|
||||
|
||||
### EC-14 🟡 Volume / mute / audio-options must NOT be gated
|
||||
Those are per-peer, not room control. The gate must target only play/pause/seek +
|
||||
forcesync/episode — not `PEER_STATUS` volume/mute fields. Easy to over-block; add a test.
|
||||
|
||||
### EC-15 🔴 Live streams (Twitch live, live DVR)
|
||||
"Room timestamp" is fuzzy on live edge; seeking semantics differ. Decide whether
|
||||
host-only even makes sense for live, or degrade gracefully. Low priority but log it.
|
||||
|
||||
### EC-16 🟡 Host's own involuntary events still drive the room
|
||||
If the host buffers and the player auto-pauses, that pause is "allowed" and pauses
|
||||
everyone. That's existing behavior, but in host-only it means the host's buffering
|
||||
stalls the whole room. Acceptable? Probably yes (host is authoritative), but note it.
|
||||
|
||||
### EC-17 🟡 Server restart drops room state
|
||||
`hostPeerId`/`controlMode` are in-memory. After a server restart, whoever rejoins first
|
||||
becomes the new host and mode resets to `everyone`. Acceptable for now (non-goal), but
|
||||
document so it's not a surprise.
|
||||
|
||||
### EC-18 🟡 Dialog dismissed without choosing
|
||||
Guest clicks away / presses Esc on the desync prompt. Default = treat as "No" → snap
|
||||
back. Make sure an un-answered dialog can't leave them in limbo (paused + not desynced +
|
||||
no dialog).
|
||||
|
||||
### EC-19 🟡 Multiple video elements / element swap (SPA, ad → content)
|
||||
Players that swap the `<video>` element mid-session: re-attach handlers (content.js
|
||||
already re-binds on `loadeddata`) and make sure host-only gating follows the new element.
|
||||
|
||||
### EC-20 🟡 Peer list shape (object vs legacy string)
|
||||
Throughout background.js peers may be objects or bare peerId strings
|
||||
(`typeof p === 'object' ? p.peerId : p`). All new `hostPeerId` comparisons must handle
|
||||
both forms, or we get a host that's never recognized.
|
||||
|
||||
### EC-21 🟡 Mode toggle spam / rate limiting
|
||||
Host hammering the toggle → many `SET_CONTROL_MODE`. Covered by existing
|
||||
`checkEventRate` (server), but debounce in the UI and ignore no-op transitions.
|
||||
|
||||
---
|
||||
|
||||
## 7. Test matrix (fill in during dev)
|
||||
|
||||
| Player | Buffer→`pause`? | Ad behavior | Snap-back works? | Element swap? | Notes |
|
||||
|-------------------|-----------------|-------------|------------------|---------------|-------|
|
||||
| Generic HTML5 | | | | | |
|
||||
| YouTube | | | | | |
|
||||
| Netflix | | | | | |
|
||||
| Twitch (VOD) | | | | | |
|
||||
| Twitch (live) | | | | | |
|
||||
| Disney+ / DRM | | | | | |
|
||||
| Jellyfin / Emby | | | | | |
|
||||
|
||||
## 8. Decisions (audited)
|
||||
- [x] **Intent-classifier (EC-9):** A `pause`/`seek` is **involuntary** if ANY of:
|
||||
`readyState < 3`, `video.seeking`, a `waiting` fired < ~1500ms ago (`isBuffering`
|
||||
flag), inside `visibilityGraceUntil`, OR no own-tracked user gesture
|
||||
(`Date.now() - lastUserGestureAt < 1000`, via capturing keydown/pointerdown — do
|
||||
NOT use sticky `navigator.userActivation.hasBeenActive`). Bias: only *clearly*
|
||||
involuntary is ignored; everything else = deliberate. **Note:** in host-only the
|
||||
guest never broadcasts anyway, so this only decides dialog-vs-silent — a UX call,
|
||||
not a room-integrity call. Start simple, tune later.
|
||||
- [x] **Host grace on disconnect (EC-10): NOT in v1.** Immediate fallback to
|
||||
`everyone` (EC-3). A grace window risks a multi-second hard-lock if the host never
|
||||
returns. Revisit as polish once the core flow works.
|
||||
- [x] **Desync semantics (EC-12): full solo.** Ignore host play/pause/seek while
|
||||
desynced; Resync snaps to host position + adopts host play/pause state. Desync
|
||||
auto-clears on new media/episode. Requires a persistent, obvious "You are desynced"
|
||||
UI.
|
||||
- [x] **Snap-back cooldown (EC-4): until-settled, not fixed.** Suppress re-trigger
|
||||
until `readyState>=3 && playing && |Δt|<tol`, hard-cap ~1500ms. Retry target up to
|
||||
3×, then give up (no infinite loop).
|
||||
- [x] **Live streams (EC-15): degrade.** Disable the gate when
|
||||
`video.duration === Infinity`. Caveat: live-DVR may report finite duration and slip
|
||||
through — acceptable for v1.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Host Control Mode — Beta Testing Guide
|
||||
|
||||
> Temporary doc for branch `feature/host-control-mode`. Remove before merge.
|
||||
> The feature needs the **server** half too — the official relay
|
||||
> (`wss://syncserver.koalastuff.net`) doesn't run it yet, so test against a beta
|
||||
> server first.
|
||||
|
||||
## 1. Run the beta relay (Docker)
|
||||
|
||||
The branch publishes the server image to GHCR under non-production tags
|
||||
(`:beta` = newest branch build, `:sha-<commit>` = immutable pin). `:latest` is
|
||||
never touched.
|
||||
|
||||
```bash
|
||||
# Log in (the package is private → PAT with read:packages)
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u <your-gh-user> --password-stdin
|
||||
|
||||
# Pull + (re)create — NOTE: `docker restart` does NOT pick up a new image,
|
||||
# you must remove and re-run (or use compose / Watchtower).
|
||||
docker pull ghcr.io/shik3i/koalasync:beta
|
||||
docker rm -f koala-beta 2>/dev/null || true
|
||||
docker run -d --name koala-beta -p 3000:3000 \
|
||||
-e SERVER_SALT='choose-your-own-salt' \
|
||||
ghcr.io/shik3i/koalasync:beta
|
||||
```
|
||||
|
||||
To always run the newest beta automatically, point **Watchtower** at the
|
||||
container — it does the pull → recreate whenever `:beta` moves.
|
||||
|
||||
The `OFFICIAL_SERVER_TOKEN` is baked into `shared/constants.js`, so no token env
|
||||
is needed. Set `SERVER_SALT` (used for room-password hashing).
|
||||
|
||||
## 2. Connect the extension to it
|
||||
|
||||
⚠️ The client **force-upgrades `ws://` to `wss://` for any non-localhost host**
|
||||
(see background.js "Upgraded to wss:// for remote host"). So a bare
|
||||
`ws://your-server:3000` will fail without TLS. Two options:
|
||||
|
||||
- **Quick (no TLS):** SSH-tunnel the port so it counts as local:
|
||||
```bash
|
||||
ssh -L 3000:localhost:3000 your-beta-server
|
||||
```
|
||||
then in the popup → **Manual Connect / Advanced → Custom →** `ws://localhost:3000`.
|
||||
- **Proper:** put the container behind a TLS reverse proxy (Caddy does automatic
|
||||
HTTPS) → `wss://beta.yourdomain`.
|
||||
|
||||
Then create/join a room.
|
||||
|
||||
## 3. ⚠️ Use two *new* clients
|
||||
|
||||
Test with **two browser profiles both running this branch build** (load unpacked
|
||||
from `extension/`, or install the built zip). A stock release client as a guest
|
||||
will be correctly gated by the server but has none of the content-side code, so
|
||||
it silently desyncs with no dialog/snap-back — that's expected degradation, not a
|
||||
bug, but it looks like one during testing.
|
||||
|
||||
## 4. Verification checklist
|
||||
|
||||
| # | Step | Expect |
|
||||
|---|------|--------|
|
||||
| 1 | Create a room (host) | Host Control card shows **Host** + the toggle |
|
||||
| 2 | Second profile joins | Guest sees no card while mode is "everyone" |
|
||||
| 3 | Host enables "Only I can control" | Guest's Play/Pause/SYNC buttons lock; card shows **Guest** |
|
||||
| 4 | Guest presses pause/space on the video | Brief flicker, snaps back to host position |
|
||||
| 5 | Guest pauses again | Dialog: "Stay in sync" / "Watch on my own" |
|
||||
| 6 | Guest → "Watch on my own" | Persistent "Solo" badge; host's peer list shows **Solo** |
|
||||
| 7 | Guest → "Resync" | Snaps back to host; Solo badge clears on both sides |
|
||||
| 8 | Guest tries Force-Sync / seek spam | Nothing propagates to the room |
|
||||
| 9 | Host disables host-only | Card hides for guest; controls unlock |
|
||||
| 10 | Host leaves the room | Room falls back to "everyone"; a remaining peer becomes host |
|
||||
| 11 | Reload the guest's page while desynced | Still shows Solo (state survives reload) |
|
||||
| 12 | Switch the popup language | Dialog/badge text is localized |
|
||||
|
||||
## 5. Capability detection
|
||||
|
||||
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`. The client
|
||||
only enables the feature when that flag is present, so:
|
||||
- against this beta server → feature on;
|
||||
- against the old official server → feature cleanly hidden (no errors).
|
||||
|
||||
This is the extensible hook for the planned **co-host** feature (owner promotes
|
||||
guests to additional controllers) — it'll add a `'co-host'` capability + events
|
||||
without breaking older relays/clients.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Host Control Mode — Implementierungsplan
|
||||
|
||||
Branch: `feature/host-control-mode`
|
||||
Issue: GitHub feature request (wasserrutschentester) — nur Host darf den Raum steuern; Gäste werden zurückgesnappt oder gehen bewusst in Desync.
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Raum kann zwischen zwei Modi umgeschaltet werden:
|
||||
- **`everyone`** (Default, heutiges Verhalten): jeder kann play/pause/seek für alle auslösen.
|
||||
- **`host-only`**: nur der Host steuert den Raum. Pause/Seek eines Gasts wird **nicht** gebroadcastet; stattdessen snappt die eigene Extension den Gast zurück auf den Raum-Zustand — es sei denn, der Gast entscheidet sich bewusst für Desync.
|
||||
|
||||
Trust-Modell: client-seitig durchgesetzt. Kein Token, keine Auth. Es geht um versehentliches Stören, nicht um Angriffsschutz.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell
|
||||
|
||||
### Server (`server/index.js`, Room-Objekt ~Z.331)
|
||||
Room bekommt zwei neue Felder:
|
||||
```js
|
||||
room = {
|
||||
...,
|
||||
hostPeerId: peerId, // gesetzt beim Anlegen = erster Joiner
|
||||
controlMode: 'everyone', // 'everyone' | 'host-only'
|
||||
}
|
||||
```
|
||||
- In `ROOM_DATA` (~Z.418) mitschicken: `hostPeerId`, `controlMode`.
|
||||
- Neues Event `SET_CONTROL_MODE` (siehe unten): nur akzeptieren, wenn `senderPeerId === room.hostPeerId`. Server setzt `room.controlMode`, broadcastet die Änderung an alle.
|
||||
- **Host-Migration:** in `removePeerFromRoom` (~Z.168) — wenn der gehende Peer `hostPeerId` war: entweder neuen Host bestimmen (nächster Peer) **oder** `controlMode` auf `everyone` zurückfallen lassen. → Entscheidung: **Fallback auf `everyone`** (simpel, nie verwaister gesperrter Raum). Optional später: Host-Transfer-Button.
|
||||
|
||||
### Shared Constants (`shared/constants.js`)
|
||||
Neue Events im `EVENTS`-Objekt:
|
||||
```js
|
||||
SET_CONTROL_MODE: "set_control_mode", // Client->Server: Host ändert Modus
|
||||
CONTROL_MODE: "control_mode", // Server->Client: Modus geändert { controlMode, hostPeerId }
|
||||
```
|
||||
⚠️ Danach `node scripts/build-extension.cjs` laufen lassen (Single Source of Truth propagieren). Ggf. `PROTOCOL_VERSION` bumpen — **nein, nur wenn alte Clients brechen würden**. Da alles additiv ist und alte Clients die neuen Felder/Events einfach ignorieren, ist KEIN Protokoll-Bump nötig. (Alter Client in host-only-Raum kennt den Modus nicht und sendet weiter → Host-Extensions ignorieren fremde Events nicht... → siehe Edge Case 7. Evtl. doch Bump erwägen.)
|
||||
|
||||
### Extension State (`background.js`)
|
||||
```js
|
||||
let controlMode = 'everyone';
|
||||
let hostPeerId = null;
|
||||
// abgeleitet: const amHost = () => hostPeerId === peerId;
|
||||
```
|
||||
Aus `ROOM_DATA` / `CONTROL_MODE` befüllen, in `chrome.storage.session` persistieren (wie `currentRoom`).
|
||||
|
||||
---
|
||||
|
||||
## Implementierung nach Schichten
|
||||
|
||||
### 1. Server (`server/index.js`)
|
||||
- [ ] Room-Objekt um `hostPeerId` + `controlMode` erweitern (~Z.331).
|
||||
- [ ] `ROOM_DATA`-Payload erweitern (~Z.418).
|
||||
- [ ] Handler `SET_CONTROL_MODE`: validieren (Host-Check + Wert in {everyone, host-only}), setzen, `CONTROL_MODE` an Raum broadcasten.
|
||||
- [ ] Host-Migration in `removePeerFromRoom`: Fallback auf `everyone` + neues `CONTROL_MODE` broadcasten, wenn Host geht.
|
||||
- [ ] `SET_CONTROL_MODE` in die `relayEvents`-Liste? **Nein** — eigener Handler, da Sonderlogik + Host-Check. (relayEvents broadcastet blind.)
|
||||
|
||||
### 2. Shared / Build
|
||||
- [ ] `EVENTS.SET_CONTROL_MODE`, `EVENTS.CONTROL_MODE` ergänzen.
|
||||
- [ ] `node scripts/build-extension.cjs`.
|
||||
|
||||
### 3. background.js (Gast-Logik = Kern)
|
||||
- [ ] `controlMode` / `hostPeerId` aus `ROOM_DATA` (~Z.875) und neuem `CONTROL_MODE`-Case übernehmen + persistieren + an Popup/Content pushen.
|
||||
- [ ] **Emit-Gate** im SEND-Pfad (~Z.1786): bei `host-only && !amHost()` und action ∈ {play, pause, seek}:
|
||||
- NICHT `emit`en.
|
||||
- Stattdessen Content-Script anweisen: "snap back" ODER Desync-Confirm anzeigen.
|
||||
- [ ] **Snap-Back-Zielzeit berechnen:** aus Host-Peer-State (`playbackState`, `currentTime`, `lastHeartbeat`) extrapolieren:
|
||||
`targetTime = host.currentTime + (host.playbackState==='playing' ? (now - host.lastHeartbeat)/1000 : 0)`.
|
||||
Genauigkeit ~±1s, für Watchparty ok. (Force-Sync-Maschinerie als Referenz für Ziel-Zeit-Koordination.)
|
||||
- [ ] Nachricht an content.js: `{ type: 'HOST_BLOCK', action, targetTime, hostPlaybackState }`.
|
||||
|
||||
### 4. content.js (Player-Reaktion + Dialog)
|
||||
- [ ] Handler für `HOST_BLOCK`: Confirm-Dialog im Player-Overlay rendern:
|
||||
"Pause only your own player and desync from the group? [Yes] [No]".
|
||||
- **No** (Default): Player via bestehende `_setSuppress`-Mechanik ([content.js:442](../extension/content.js:442)) wieder in Raum-Zustand zwingen (play + seek auf targetTime). Suppress verhindert Re-Broadcast.
|
||||
- **Yes:** lokal pausiert lassen, `isDesynced = true` setzen, dezenten "Desynced — Resync"-Button zeigen.
|
||||
- [ ] "Resync"-Button → snappt zurück auf aktuelle Raum-Zeit, `isDesynced = false`.
|
||||
- [ ] **Loop-Schutz:** nach einer Snap-Back-Aktion kurzes Cooldown-Fenster (z.B. 600ms), in dem weitere lokale pause/seek-Events nicht erneut den Dialog triggern (verhindert pause→play→pause-Pingpong).
|
||||
|
||||
### 5. popup (Host-UI)
|
||||
- [ ] Host-Toggle "Only I can control" (nur sichtbar wenn `amHost()`), sendet `SET_CONTROL_MODE`.
|
||||
- [ ] Rollen-Badge: "Host" / "Guest" + aktueller Modus.
|
||||
- [ ] Gast-Hinweis wenn host-only aktiv: "The host controls playback".
|
||||
- [ ] i18n-Keys in `extension/_locales` / `locales` für ~15 Sprachen.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases (Test-Checkliste)
|
||||
|
||||
1. **Pause nicht verhinderbar, nur revidierbar** → kurzer Flicker (~½s) beim Gast ist erwartet/akzeptabel.
|
||||
2. **Snap-Back-Zielzeit** aus Heartbeat extrapoliert, ±1s. Bei stark veraltetem Host-State (kein Heartbeat) → letzten bekannten Wert nehmen.
|
||||
3. **Kampf-Loop pause/play/skip back/pause/play** → Cooldown-Fenster nach Snap-Back. Testen mit aggressivem Mashing.
|
||||
4. **Desync-Escape:** Gast kann bewusst pausieren (Klo/Telefon) → "Yes" → solo, dann Resync.
|
||||
5. **Host verlässt Raum** → Fallback auf `everyone`, alle bekommen `CONTROL_MODE`-Update. Testen: Host schließt Tab / Disconnect / Netzabbruch.
|
||||
6. **host-only + Episode-Auto-Sync / Force-Sync** (server/index.js:503-516): **Gast darf NICHT initiieren.** Force-Sync trägt eine `targetTime` und zwingt ALLE darauf (background.js:1261) — ein Gast könnte seeken → Force-Sync spammen und damit host-only komplett aushebeln. Episode-Lobby pausiert ebenfalls alle. → Im host-only-Modus dürfen `FORCE_SYNC_PREPARE`/`FORCE_SYNC_EXECUTE` und `EPISODE_LOBBY` nur vom Host **initiiert** werden. Gäste dürfen weiterhin nur **reagieren**: `FORCE_SYNC_ACK`, `EPISODE_READY`. Gäste brauchen Force-Sync nicht — ihr legitimer Fall ist der "Resync"-Button (snappt nur sie selbst, nicht alle).
|
||||
7. **Alter Client (ohne Feature) in host-only-Raum** → kennt Modus nicht, sendet weiter pause/seek → andere Extensions wenden es an. Mitigation: Empfänger-seitiges Gate (host-only-Clients ignorieren play/pause/seek von Nicht-Host) ODER `MIN_VERSION`/Protokoll-Bump. → **Empfehlung: zusätzlich Empfänger-seitig filtern** (robuster als nur Sender-Gate).
|
||||
8. **Seek getrennt von Pause** → host-only blockt auch Gast-Seeks, nicht nur Pausen.
|
||||
9. **Mehrere Tabs / Multi-Peer mit gleicher peerId** (Dedup, server:381) → Host-Identität bleibt an peerId hängen, ok.
|
||||
10. **DAU-Verwirrung** "warum kann ich nicht mehr pausieren?" → klare UI-Botschaft + der Desync-Dialog erklärt sich selbst.
|
||||
|
||||
## Architektur-Entscheidung zu Edge Case 7 (wichtig)
|
||||
Wir setzen das Gate **doppelt** und über **alle raum-verschiebenden Events**, nicht nur play/pause/seek:
|
||||
Geblockte Initiierungen für Nicht-Host im host-only-Modus:
|
||||
`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`.
|
||||
Weiterhin erlaubt für Gäste (reine Reaktion, verschiebt niemanden): `FORCE_SYNC_ACK`, `EPISODE_READY`, `PEER_STATUS`, `PING`/`PONG`.
|
||||
|
||||
- **Sender-seitig** (Gast sendet erst gar nicht) → saubere UX, Confirm-Dialog bei play/pause/seek; Force-Sync-/Episode-Lobby-Buttons im Gast-UI deaktiviert/ausgeblendet.
|
||||
- **Empfänger-seitig** (in `handleServerEvent`, background.js:969 + Force-Sync-/Episode-Cases): wenn `host-only` und `data.senderId !== hostPeerId` → Event verwerfen (nicht an Content routen, keine State-Mutation).
|
||||
So sind auch alte/buggy/manipulierte Clients abgedeckt, ohne harten Protokoll-Bump.
|
||||
|
||||
Optional zusätzlich **server-seitig** in den `relayEvents` (server/index.js:445): im host-only-Modus Initiierungs-Events von Nicht-Host gar nicht erst relayen. Spart Traffic + deckt alles zentral ab. Empfehlenswert, da der Server `hostPeerId`/`controlMode` ohnehin kennt.
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge der Umsetzung (kleine, testbare Schritte)
|
||||
1. Constants + Build (Events da, nichts kaputt).
|
||||
2. Server: hostPeerId/controlMode + ROOM_DATA + SET_CONTROL_MODE + Migration.
|
||||
3. background.js: State übernehmen + Empfänger-seitiges Gate (Edge 7) — testbar ohne UI.
|
||||
4. background.js: Sender-seitiges Gate + Snap-Back-Zielzeit.
|
||||
5. content.js: Snap-Back-Apply + Confirm-Dialog + Loop-Cooldown.
|
||||
6. popup: Host-Toggle + Badge + i18n.
|
||||
7. Durchtesten der Edge-Case-Liste auf YT / Netflix / generischem HTML5-Player.
|
||||
@@ -8,6 +8,7 @@ export default [
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
chrome: "readonly",
|
||||
browser: "readonly",
|
||||
window: "readonly",
|
||||
document: "readonly",
|
||||
navigator: "readonly",
|
||||
@@ -36,8 +37,10 @@ export default [
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
WebSocket: "readonly",
|
||||
history: "readonly",
|
||||
location: "readonly",
|
||||
self: "readonly",
|
||||
process: "readonly"
|
||||
process: "readonly",
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
@@ -55,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",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Prometheus Community JSON Exporter Configuration Example
|
||||
# File: examples/json_exporter.example.yml
|
||||
#
|
||||
# Use this configuration to map KoalaSync admin health metrics (JSON)
|
||||
# to native Prometheus metrics.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Rename this file to json_exporter.yml
|
||||
# 2. Replace "YOUR_ADMIN_METRICS_TOKEN" with your actual ADMIN_METRICS_TOKEN env value
|
||||
# 3. Mount it to the json-exporter docker container: /config.yml
|
||||
|
||||
modules:
|
||||
koalasync:
|
||||
http_client_config:
|
||||
bearer_token: "YOUR_ADMIN_METRICS_TOKEN"
|
||||
metrics:
|
||||
- name: koalasync_uptime_seconds
|
||||
path: '{.uptime}'
|
||||
help: "Uptime of the KoalaSync relay server in seconds"
|
||||
|
||||
- name: koalasync_rooms
|
||||
path: '{.rooms}'
|
||||
help: "Total active rooms"
|
||||
|
||||
- name: koalasync_connections
|
||||
path: '{.connections}'
|
||||
help: "Total active socket connections (sockets)"
|
||||
|
||||
- name: koalasync_peers
|
||||
path: '{.peers}'
|
||||
help: "Total connected peers across all rooms"
|
||||
|
||||
- name: koalasync_rooms_with_lobby
|
||||
path: '{.roomsWithLobby}'
|
||||
help: "Number of rooms waiting in an episode lobby"
|
||||
|
||||
- name: koalasync_avg_peers_per_room
|
||||
path: '{.avgPeersPerRoom}'
|
||||
help: "Average number of peers per room"
|
||||
|
||||
- name: koalasync_max_peers_in_room
|
||||
path: '{.maxPeersInRoom}'
|
||||
help: "Maximum number of peers in a single room"
|
||||
|
||||
- name: koalasync_memory_rss_bytes
|
||||
path: '{.memory.rss}'
|
||||
help: "Resident Set Size (RSS) memory usage in bytes"
|
||||
|
||||
- name: koalasync_memory_heap_used_bytes
|
||||
path: '{.memory.heapUsed}'
|
||||
help: "V8 engine heap used in bytes"
|
||||
|
||||
- name: koalasync_memory_heap_total_bytes
|
||||
path: '{.memory.heapTotal}'
|
||||
help: "V8 engine heap total in bytes"
|
||||
|
||||
# Rate limiter tracking — unique clients currently in each tracking window
|
||||
# (not rate-limit denials; these include legitimate traffic too)
|
||||
|
||||
- name: koalasync_rate_limit_connections
|
||||
path: '{.rateLimits.trackedClients.connections}'
|
||||
help: "Unique clients tracked in the connection rate limiter window"
|
||||
|
||||
- name: koalasync_rate_limit_events
|
||||
path: '{.rateLimits.trackedClients.events}'
|
||||
help: "Unique sockets tracked in the event rate limiter window"
|
||||
|
||||
- name: koalasync_rate_limit_health
|
||||
path: '{.rateLimits.trackedClients.health}'
|
||||
help: "Unique IPs tracked in the health endpoint rate limiter window"
|
||||
|
||||
- name: koalasync_rate_limit_admin_metrics_auth
|
||||
path: '{.rateLimits.trackedClients.adminMetricsAuth}'
|
||||
help: "Unique IPs tracked in the admin metrics auth rate limiter window"
|
||||
|
||||
- name: koalasync_rate_limit_auth_failures
|
||||
path: '{.rateLimits.trackedClients.authFailures}'
|
||||
help: "Unique IPs tracked in the authentication failures cache"
|
||||
|
||||
- name: koalasync_rate_limit_room_list
|
||||
path: '{.rateLimits.trackedClients.roomList}'
|
||||
help: "Unique sockets in the room list cooldown cache"
|
||||
|
||||
# Actual rate-limit denials — incremented only when a 429 is served
|
||||
|
||||
- name: koalasync_rate_limit_denied_connections
|
||||
path: '{.rateLimits.denied.connections}'
|
||||
help: "Total connection attempts denied by rate limiter"
|
||||
|
||||
- name: koalasync_rate_limit_denied_events
|
||||
path: '{.rateLimits.denied.events}'
|
||||
help: "Total socket events denied by rate limiter"
|
||||
|
||||
- name: koalasync_rate_limit_denied_health
|
||||
path: '{.rateLimits.denied.health}'
|
||||
help: "Total health endpoint requests denied by rate limiter"
|
||||
|
||||
- name: koalasync_rate_limit_denied_admin_metrics_auth
|
||||
path: '{.rateLimits.denied.adminMetricsAuth}'
|
||||
help: "Total admin metrics auth attempts denied by rate limiter"
|
||||
|
||||
- name: koalasync_rate_limit_denied_room_list
|
||||
path: '{.rateLimits.denied.roomList}'
|
||||
help: "Total room list refresh requests denied by rate limiter"
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
|
||||
}
|
||||
}
|
||||
@@ -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 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5."
|
||||
}
|
||||
}
|
||||
@@ -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 ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
|
||||
}
|
||||
}
|
||||
@@ -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网站上的视频播放。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--panel: #172033;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--text: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #334155;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--accent-hover);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.muted-panel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.master-toggle,
|
||||
.inline-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.master-toggle,
|
||||
.inline-toggle {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preset-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.preset-card {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.preset-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.preset-card:has(input:checked) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.preset-card input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.custom-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: var(--radius);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(160px, 1fr) 78px 30px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.control-row label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input[type="number"]:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #334155;
|
||||
transition: .3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #94a3b8;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.page-header,
|
||||
.section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
grid-template-columns: 1fr 74px 28px;
|
||||
}
|
||||
|
||||
.control-row label {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>KoalaSync Audio Settings</title>
|
||||
<link rel="stylesheet" href="audio-options.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page-shell">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a id="backLink" href="#" class="back-link" data-i18n="AUDIO_BACK">← Back</a>
|
||||
<p class="eyebrow">KoalaSync</p>
|
||||
<h1 data-i18n="AUDIO_PAGE_TITLE">Audio Settings</h1>
|
||||
</div>
|
||||
<label class="master-toggle">
|
||||
<span data-i18n="AUDIO_MASTER_TOGGLE">Audio Processing</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="audioEnabled">
|
||||
<span class="slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-heading">
|
||||
<h2 data-i18n="AUDIO_COMPRESSOR">Compressor</h2>
|
||||
<label class="inline-toggle">
|
||||
<span data-i18n="AUDIO_COMPRESSOR_ENABLE">Enabled</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="compressorEnabled">
|
||||
<span class="slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="preset-group" role="radiogroup" aria-label="Compressor preset">
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="recommended">
|
||||
<span data-i18n="AUDIO_PRESET_RECOMMENDED">Recommended</span>
|
||||
</label>
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="dynamicRange">
|
||||
<span data-i18n="AUDIO_PRESET_DYNAMIC_RANGE">Dynamic Range</span>
|
||||
</label>
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="vocalEnhancement">
|
||||
<span data-i18n="AUDIO_PRESET_VOCAL_ENHANCEMENT">Vocal Enhancement</span>
|
||||
</label>
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="smooth">
|
||||
<span data-i18n="AUDIO_PRESET_SMOOTH">Smooth</span>
|
||||
</label>
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="custom">
|
||||
<span data-i18n="AUDIO_PRESET_CUSTOM">Custom</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="custom-grid" id="customControls">
|
||||
<div class="control-row" data-param="threshold">
|
||||
<label data-i18n="AUDIO_PARAM_THRESHOLD">Threshold</label>
|
||||
<input type="range" min="-60" max="0" step="1">
|
||||
<input type="number" min="-60" max="0" step="1">
|
||||
<span class="unit">dB</span>
|
||||
</div>
|
||||
<div class="control-row" data-param="knee">
|
||||
<label data-i18n="AUDIO_PARAM_KNEE">Knee</label>
|
||||
<input type="range" min="0" max="40" step="1">
|
||||
<input type="number" min="0" max="40" step="1">
|
||||
<span class="unit">dB</span>
|
||||
</div>
|
||||
<div class="control-row" data-param="ratio">
|
||||
<label data-i18n="AUDIO_PARAM_RATIO">Ratio</label>
|
||||
<input type="range" min="1" max="20" step="0.5">
|
||||
<input type="number" min="1" max="20" step="0.5">
|
||||
<span class="unit">:1</span>
|
||||
</div>
|
||||
<div class="control-row" data-param="attack">
|
||||
<label data-i18n="AUDIO_PARAM_ATTACK">Attack</label>
|
||||
<input type="range" min="0" max="1" step="0.001">
|
||||
<input type="number" min="0" max="1000" step="1" data-ms-input="true">
|
||||
<span class="unit">ms</span>
|
||||
</div>
|
||||
<div class="control-row" data-param="release">
|
||||
<label data-i18n="AUDIO_PARAM_RELEASE">Release</label>
|
||||
<input type="range" min="0" max="1" step="0.005">
|
||||
<input type="number" min="0" max="1000" step="5" data-ms-input="true">
|
||||
<span class="unit">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel muted-panel">
|
||||
<div class="section-heading">
|
||||
<h2 data-i18n="AUDIO_EQUALIZER">Equalizer</h2>
|
||||
</div>
|
||||
<p data-i18n="AUDIO_COMING_SOON">Coming soon</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="audio-options.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,192 @@
|
||||
import { loadLocale, translateDOM, getSystemLanguage } from './i18n.js';
|
||||
|
||||
const PRESETS = {
|
||||
recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 },
|
||||
dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 },
|
||||
vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 },
|
||||
smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 },
|
||||
custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 }
|
||||
};
|
||||
|
||||
const DEFAULT_AUDIO_SETTINGS = {
|
||||
enabled: false,
|
||||
compressor: {
|
||||
enabled: false,
|
||||
preset: 'recommended',
|
||||
customParams: { ...PRESETS.custom }
|
||||
}
|
||||
};
|
||||
const PARAM_LIMITS = {
|
||||
threshold: { min: -60, max: 0 },
|
||||
knee: { min: 0, max: 40 },
|
||||
ratio: { min: 1, max: 20 },
|
||||
attack: { min: 0, max: 1 },
|
||||
release: { min: 0, max: 1 }
|
||||
};
|
||||
|
||||
const elements = {
|
||||
audioEnabled: document.getElementById('audioEnabled'),
|
||||
compressorEnabled: document.getElementById('compressorEnabled'),
|
||||
presetInputs: Array.from(document.querySelectorAll('input[name="preset"]')),
|
||||
controlRows: Array.from(document.querySelectorAll('.control-row')),
|
||||
backLink: document.getElementById('backLink')
|
||||
};
|
||||
|
||||
let saveTimer = null;
|
||||
let isRendering = false;
|
||||
|
||||
function cloneDefaultSettings() {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_AUDIO_SETTINGS));
|
||||
}
|
||||
|
||||
let currentSettings = cloneDefaultSettings();
|
||||
|
||||
function mergeAudioSettings(settings = {}) {
|
||||
const safeSettings = settings && typeof settings === 'object' ? settings : {};
|
||||
const defaults = cloneDefaultSettings();
|
||||
return {
|
||||
...defaults,
|
||||
...safeSettings,
|
||||
compressor: {
|
||||
...defaults.compressor,
|
||||
...(safeSettings.compressor || {}),
|
||||
customParams: {
|
||||
...defaults.compressor.customParams,
|
||||
...(safeSettings.compressor?.customParams || {})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function debounceSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
chrome.storage.local.set({ audioSettings: currentSettings });
|
||||
}, 40);
|
||||
}
|
||||
|
||||
function getParamValue(param, value, isMsInput = false) {
|
||||
const parsed = Number(value);
|
||||
const candidate = Number.isFinite(parsed)
|
||||
? (isMsInput ? parsed / 1000 : parsed)
|
||||
: currentSettings.compressor.customParams[param];
|
||||
const limits = PARAM_LIMITS[param];
|
||||
if (!limits) return candidate;
|
||||
return Math.min(limits.max, Math.max(limits.min, candidate));
|
||||
}
|
||||
|
||||
function formatNumber(value, param, isMsInput = false) {
|
||||
if (isMsInput) return Math.round(value * 1000);
|
||||
if (param === 'ratio') return Number(value).toFixed(1).replace(/\.0$/, '');
|
||||
return value;
|
||||
}
|
||||
|
||||
function render() {
|
||||
isRendering = true;
|
||||
elements.audioEnabled.checked = currentSettings.enabled === true;
|
||||
elements.compressorEnabled.checked = currentSettings.compressor.enabled === true;
|
||||
|
||||
const selectedPreset = currentSettings.compressor.preset || 'recommended';
|
||||
elements.presetInputs.forEach(input => {
|
||||
input.checked = input.value === selectedPreset;
|
||||
});
|
||||
|
||||
const params = selectedPreset === 'custom'
|
||||
? currentSettings.compressor.customParams
|
||||
: PRESETS[selectedPreset] || PRESETS.recommended;
|
||||
|
||||
elements.controlRows.forEach(row => {
|
||||
const param = row.dataset.param;
|
||||
const range = row.querySelector('input[type="range"]');
|
||||
const number = row.querySelector('input[type="number"]');
|
||||
const value = params[param];
|
||||
range.value = value;
|
||||
number.value = formatNumber(value, param, number.dataset.msInput === 'true');
|
||||
});
|
||||
isRendering = false;
|
||||
}
|
||||
|
||||
function setPreset(preset) {
|
||||
currentSettings.compressor.preset = preset;
|
||||
if (preset === 'custom') {
|
||||
currentSettings.compressor.customParams = {
|
||||
...PRESETS.custom,
|
||||
...currentSettings.compressor.customParams
|
||||
};
|
||||
}
|
||||
render();
|
||||
debounceSave();
|
||||
}
|
||||
|
||||
function setCustomParam(param, value) {
|
||||
currentSettings.compressor.preset = 'custom';
|
||||
currentSettings.compressor.customParams[param] = getParamValue(param, value);
|
||||
render();
|
||||
debounceSave();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// 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(audioSettings);
|
||||
render();
|
||||
}
|
||||
|
||||
elements.audioEnabled.addEventListener('change', () => {
|
||||
currentSettings.enabled = elements.audioEnabled.checked;
|
||||
if (currentSettings.enabled && !currentSettings.compressor.enabled) {
|
||||
currentSettings.compressor.enabled = true;
|
||||
}
|
||||
render();
|
||||
debounceSave();
|
||||
});
|
||||
|
||||
elements.compressorEnabled.addEventListener('change', () => {
|
||||
currentSettings.compressor.enabled = elements.compressorEnabled.checked;
|
||||
if (currentSettings.compressor.enabled) currentSettings.enabled = true;
|
||||
render();
|
||||
debounceSave();
|
||||
});
|
||||
|
||||
elements.presetInputs.forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
if (input.checked) setPreset(input.value);
|
||||
});
|
||||
});
|
||||
|
||||
elements.controlRows.forEach(row => {
|
||||
const param = row.dataset.param;
|
||||
const range = row.querySelector('input[type="range"]');
|
||||
const number = row.querySelector('input[type="number"]');
|
||||
|
||||
range.addEventListener('input', () => {
|
||||
if (isRendering) return;
|
||||
setCustomParam(param, getParamValue(param, range.value));
|
||||
});
|
||||
|
||||
number.addEventListener('input', () => {
|
||||
if (isRendering) return;
|
||||
setCustomParam(param, getParamValue(param, number.value, number.dataset.msInput === 'true'));
|
||||
});
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
currentSettings = mergeAudioSettings(changes.audioSettings.newValue);
|
||||
render();
|
||||
});
|
||||
|
||||
if (elements.backLink) {
|
||||
elements.backLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
|
||||
init().catch(err => {
|
||||
console.error('[AudioOptions] Failed to initialize:', err);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ document.documentElement.dataset.koalasyncInstalled = 'true';
|
||||
|
||||
// 2. Listen for Join Requests from the Website
|
||||
window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
|
||||
if (!e || !e.detail) return;
|
||||
const { roomId, password, useCustomServer, serverUrl } = e.detail;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'WEB_JOIN_REQUEST',
|
||||
@@ -17,11 +18,12 @@ window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
|
||||
password,
|
||||
useCustomServer,
|
||||
serverUrl
|
||||
});
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// 3. Listen for Status Updates from the Extension and relay to Website
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (!msg) return;
|
||||
if (msg.type === 'JOIN_STATUS') {
|
||||
const detail = { success: msg.success, message: msg.message };
|
||||
// Firefox MV3 content scripts run in an isolated world. When dispatching
|
||||
|
||||
@@ -55,6 +55,30 @@
|
||||
const MIN_SEEK_DELTA = 2.0;
|
||||
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
|
||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||
let expectedSeekTime = null; // strictly track programmatic seeks
|
||||
|
||||
// --- Play/Pause Coalescing (leading + trailing) ---
|
||||
// Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps,
|
||||
// page teardown) fire bursts of native play/pause events within a few hundred
|
||||
// ms. Relaying each as a distinct command spams peers and the relay.
|
||||
//
|
||||
// Strategy: emit the FIRST event immediately (leading edge → a deliberate
|
||||
// single play/pause has zero added latency), then hold a short window. If
|
||||
// more play/pause events arrive during the window it's a burst — we suppress
|
||||
// the intermediate churn and, once it settles, emit the FINAL state on the
|
||||
// trailing edge.
|
||||
//
|
||||
// We deliberately do NOT dedup the trailing send against the leading one. A
|
||||
// remote play/pause may be applied mid-window (e.g. I pause, peer plays, I
|
||||
// pause again within 150ms): the settled state then equals my last *sent*
|
||||
// state yet is a genuine change versus the now-shared state — suppressing it
|
||||
// would desync. Re-sending an unchanged state is a harmless no-op on peers,
|
||||
// so re-sending is always the safe choice. Echo-suppression and seek-flush
|
||||
// still run synchronously on event arrival (see reportEvent) — only the
|
||||
// network emit is governed here.
|
||||
const PLAY_PAUSE_COALESCE_MS = 150;
|
||||
let playPauseCoalesceTimer = null; // non-null = a coalescing window is open
|
||||
let pendingPlayPauseAction = null; // last play/pause seen during the window, awaiting trailing flush
|
||||
|
||||
// --- Episode Auto-Sync State ---
|
||||
let lastKnownMediaTitle = null;
|
||||
@@ -62,17 +86,322 @@
|
||||
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
let lobbyPollTimer = null;
|
||||
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
|
||||
let _audioSettings = null;
|
||||
let _audioProcessingAllowed = true;
|
||||
|
||||
// Cache the autoSyncNextEpisode setting
|
||||
chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => {
|
||||
_autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled
|
||||
// Cache the autoSyncNextEpisode setting (local-only; never read from sync)
|
||||
chrome.storage.local.get(['autoSyncNextEpisode', 'audioSettings'], (data) => {
|
||||
_autoSyncEnabled = data.autoSyncNextEpisode !== false;
|
||||
_audioSettings = mergeAudioSettings(data.audioSettings);
|
||||
const video = findVideo();
|
||||
if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
|
||||
});
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'sync' && changes.autoSyncNextEpisode) {
|
||||
if (area === 'local' && changes.autoSyncNextEpisode) {
|
||||
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Host Control Mode (guest-side) ---
|
||||
// When a room is in 'host-only' mode and we're a guest, a deliberate local
|
||||
// pause/seek must not drive the room (background/server already drop it). Here
|
||||
// we handle the *local* UX: snap back to the host's position, or — if the user
|
||||
// really wants to — let them go solo (desync) with a resync escape hatch.
|
||||
let hcmControlMode = 'everyone'; // mirror of room control mode
|
||||
let hcmAmController = false; // are we allowed to drive (owner or co-host)?
|
||||
let hcmHostPeerId = null; // last known host peerId (room/host identity)
|
||||
let hcmDesynced = false; // user chose to go solo
|
||||
let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back
|
||||
let hcmDeferredSnapPending = false; // a buffer-aware snap-back is waiting for readiness
|
||||
let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification
|
||||
let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window
|
||||
let hcmDialogTimer = null; // 8s auto-stay timer — cleared on dialog replace (H-4)
|
||||
// Localized strings for the in-page dialog/badge (content has no i18n loader;
|
||||
// background resolves them via GET_HCM_STRINGS on init). English fallbacks here.
|
||||
const hcmStrings = {
|
||||
title: 'KoalaSync · Host controls this room',
|
||||
body: 'Only the host can control playback in this room. Keep watching together, or watch on your own?',
|
||||
stay: 'Stay in sync',
|
||||
solo: 'Watch on my own',
|
||||
badge: 'Watching on your own',
|
||||
resync: 'Resync'
|
||||
};
|
||||
const HCM_USER_GESTURE_MS = 1000;
|
||||
const HCM_BUFFERING_GRACE_MS = 1500;
|
||||
const HCM_SNAP_BACK_COOLDOWN_MS = 1000;
|
||||
const HCM_BUFFER_WAIT_MS = 8000; // cap on waiting for a buffering player before snapping anyway
|
||||
|
||||
// Track genuine user input so we can tell a deliberate pause/seek from a
|
||||
// player-/browser-initiated one. Capturing + passive so we never interfere.
|
||||
const _hcmGesture = () => { hcmLastUserGestureAt = Date.now(); };
|
||||
document.addEventListener('keydown', _hcmGesture, { capture: true, passive: true });
|
||||
document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true });
|
||||
|
||||
function hcmIsGuestGated() {
|
||||
return hcmControlMode === 'host-only' && !hcmAmController;
|
||||
}
|
||||
|
||||
// EC-9 intent classifier: only a *clearly deliberate* guest action triggers the
|
||||
// dialog/snap-back. Anything that smells involuntary (buffering, seeking, tab
|
||||
// refocus, no recent gesture) is treated as involuntary. Bias intentional —
|
||||
// in host-only the guest never broadcasts anyway, so this only tunes UX.
|
||||
function hcmClassifyIntent() {
|
||||
const video = findVideo();
|
||||
if (!video) return 'involuntary';
|
||||
if (hcmIsLive(video)) return 'live'; // EC-15: degrade, don't gate
|
||||
if (video.readyState < 3) return 'involuntary'; // buffering / not enough data
|
||||
if (video.seeking) return 'involuntary';
|
||||
if (Date.now() < hcmBufferingUntil) return 'involuntary';
|
||||
if (Date.now() < visibilityGraceUntil) return 'involuntary';
|
||||
if (Date.now() - hcmLastUserGestureAt > HCM_USER_GESTURE_MS) return 'involuntary';
|
||||
return 'deliberate';
|
||||
}
|
||||
|
||||
// Live detection (EC-15 + DVR). Pure live reports duration Infinity/NaN. Live-DVR
|
||||
// (Twitch/YouTube-live with rewind) reports a *finite, sliding* duration — its
|
||||
// seekable window doesn't start at 0, which we use as the DVR signal.
|
||||
function hcmIsLive(video) {
|
||||
// Don't trust duration before metadata has loaded (readyState >= 1) —
|
||||
// otherwise pre-loaded videos report NaN and get misclassified as live,
|
||||
// which suppresses the desync dialog (L-2).
|
||||
if (video.readyState < 1) return false;
|
||||
if (!Number.isFinite(video.duration)) return true;
|
||||
try {
|
||||
const s = video.seekable;
|
||||
if (s && s.length > 0 && s.start(0) > 1) return true; // sliding DVR window
|
||||
} catch (_e) { /* seekable may throw if empty */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Snap the local player back to the host's current position/state.
|
||||
function hcmSnapBackToHost(target) {
|
||||
if (hcmDesynced) return; // user opted out — never yank them back automatically
|
||||
hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS;
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
if (target && Number.isFinite(target.targetTime)) {
|
||||
tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime });
|
||||
}
|
||||
// Adopt the host's play/pause state — but ONLY if we actually know it.
|
||||
// Defaulting to PLAY when the state is unknown would auto-resume a paused
|
||||
// video against the host's real state (H-1).
|
||||
if (target && target.playbackState === 'paused') {
|
||||
tryMediaAction(EVENTS.PAUSE);
|
||||
} else if (target && target.playbackState === 'playing') {
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
}
|
||||
reportLog('Host-only: snapped back to host position', 'info');
|
||||
}
|
||||
|
||||
// Resync to the host's current position, retrying briefly if the host's state
|
||||
// isn't known yet (e.g. they just paused and no heartbeat has propagated) —
|
||||
// otherwise the request is a silent no-op and the user thinks they're synced
|
||||
// when they aren't. Shared by the dialog's "Stay in sync" and the Resync badge.
|
||||
function hcmRequestHostSyncWithRetry() {
|
||||
let attempts = 0;
|
||||
const tryOnce = () => {
|
||||
chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => {
|
||||
if (chrome.runtime.lastError || !res || !res.target) {
|
||||
if (++attempts < 5) setTimeout(tryOnce, 250);
|
||||
else reportLog('Host-only: resync requested but host state unavailable', 'warn');
|
||||
return;
|
||||
}
|
||||
hcmSnapBackToHost(res.target);
|
||||
});
|
||||
};
|
||||
tryOnce();
|
||||
}
|
||||
|
||||
// Buffer-aware snap-back (#3). An involuntary pause/seek often coincides with the
|
||||
// player buffering — and a player can't actually play while it's stalled. Snapping
|
||||
// immediately just fights the buffer (seek → re-buffer → another pause → …), which
|
||||
// looks like stutter. Instead: if the player isn't ready, wait until it can play
|
||||
// (readyState>=3, not seeking), then snap ONCE to the host's *current* position
|
||||
// (re-queried, since the captured target may be stale by the time buffering ends).
|
||||
// Player-agnostic on purpose — we can't enumerate every site, so this must be safe
|
||||
// regardless of whether a given player fires 'pause' or only 'waiting'.
|
||||
function hcmDeferredSnapBack() {
|
||||
if (hcmDeferredSnapPending) return; // already waiting — don't stack polls
|
||||
hcmDeferredSnapPending = true;
|
||||
const deadline = Date.now() + HCM_BUFFER_WAIT_MS;
|
||||
const poll = () => {
|
||||
// Abort if the reason to snap is gone: user went solo, we're no longer a
|
||||
// gated guest, or the video vanished.
|
||||
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
|
||||
const video = findVideo();
|
||||
const ready = video && video.readyState >= 3 && !video.seeking;
|
||||
if (ready || Date.now() >= deadline) {
|
||||
hcmDeferredSnapPending = false;
|
||||
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 300);
|
||||
};
|
||||
poll();
|
||||
}
|
||||
|
||||
// Entry point: background told us our local action was blocked in host-only.
|
||||
function hcmHandleBlocked(action, target) {
|
||||
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
|
||||
// host-only + !host before sending), so it's authoritative. Adopt the
|
||||
// role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet
|
||||
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
|
||||
hcmControlMode = 'host-only';
|
||||
hcmAmController = false;
|
||||
if (hcmDesynced) return; // already solo, nothing to do
|
||||
|
||||
const intent = hcmClassifyIntent();
|
||||
if (intent === 'live') return; // EC-15: leave the guest alone on live
|
||||
if (intent === 'involuntary') {
|
||||
// EC-4 loop guard: only the silent auto snap-back is suppressed by the
|
||||
// cooldown — the deliberate dialog path below must still go through,
|
||||
// otherwise a second deliberate pause inside the cooldown window leaves
|
||||
// the user stuck paused with no UI (M-3).
|
||||
if (Date.now() < hcmSnapBackCooldownUntil || hcmDeferredSnapPending) return;
|
||||
// Buffering/ads/throttle — silently re-sync, no dialog spam.
|
||||
const video = findVideo();
|
||||
if (video && video.readyState >= 3 && !video.seeking) {
|
||||
// Ready now → snap immediately. Use the captured target if it's
|
||||
// usable, otherwise re-query+retry (host state may not be known yet)
|
||||
// so we never leave the guest silently stuck (consistent with the
|
||||
// deferred and "Stay in sync" paths).
|
||||
if (target && Number.isFinite(target.targetTime)) hcmSnapBackToHost(target);
|
||||
else hcmRequestHostSyncWithRetry();
|
||||
} else {
|
||||
hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3)
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Deliberate: offer the choice (Teleparty-style), default = snap back.
|
||||
hcmShowDesyncDialog(action, target);
|
||||
}
|
||||
|
||||
// --- In-page UI (dialog + persistent desync badge) ---
|
||||
// Built with the DOM API (CSSOM .style is CSP-safe; inline style="" in innerHTML
|
||||
// is stripped by strict style-src on Netflix/YouTube/Disney+). Hosted in a
|
||||
// Shadow DOM so the page's CSS can't restyle or hide our controls.
|
||||
let hcmDialogHost = null; // shadow host element for the dialog
|
||||
let hcmBadgeHost = null; // shadow host element for the persistent badge
|
||||
let hcmBadgePending = false; // retry flag for early-injection badge creation (L-4)
|
||||
|
||||
function hcmEl(tag, css, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (css) el.style.cssText = css; // CSSOM assignment — not gated by CSP
|
||||
if (text != null) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function hcmRemoveDialog() {
|
||||
// Cancel any pending auto-stay timer so a replaced dialog's stale closure
|
||||
// can't later remove its successor / snap to an outdated target (H-4).
|
||||
if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; }
|
||||
if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; }
|
||||
}
|
||||
|
||||
function hcmShowDesyncDialog(action, target) {
|
||||
if (!document.body) { hcmSnapBackToHost(target); return; }
|
||||
hcmRemoveDialog();
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
|
||||
const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#1f2937;color:#f9fafb;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #374151');
|
||||
wrap.setAttribute('role', 'dialog');
|
||||
const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title);
|
||||
const body = hcmEl('div', 'margin-bottom:12px;color:#d1d5db', hcmStrings.body);
|
||||
const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end');
|
||||
const soloBtn = hcmEl('button', 'background:#374151;color:#f9fafb;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo);
|
||||
const stayBtn = hcmEl('button', 'background:#10b981;color:#062a20;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay);
|
||||
btnRow.append(soloBtn, stayBtn);
|
||||
wrap.append(title, body, btnRow);
|
||||
root.appendChild(wrap);
|
||||
document.body.appendChild(host);
|
||||
hcmDialogHost = host;
|
||||
|
||||
let settled = false;
|
||||
// Re-query the host's current position on click instead of using the
|
||||
// potentially stale target captured at HOST_BLOCKED time (M-1).
|
||||
const stay = () => {
|
||||
if (settled) return; settled = true; hcmRemoveDialog();
|
||||
hcmRequestHostSyncWithRetry();
|
||||
};
|
||||
const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); };
|
||||
stayBtn.addEventListener('click', stay);
|
||||
soloBtn.addEventListener('click', solo);
|
||||
// EC-18: if the user ignores the prompt, default to staying in sync.
|
||||
hcmDialogTimer = setTimeout(() => { if (!settled) stay(); }, 8000);
|
||||
}
|
||||
|
||||
function hcmEnterDesync() {
|
||||
hcmDesynced = true;
|
||||
reportLog('Host-only: you chose to watch on your own (desynced)', 'warn');
|
||||
// Notify background so it can relay our desynced state to the host via
|
||||
// heartbeats — the host's UI then knows we're not following commands
|
||||
// instead of appearing silently un-ACK'd.
|
||||
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {});
|
||||
hcmShowBadge();
|
||||
}
|
||||
|
||||
function hcmExitDesync() {
|
||||
const wasDesynced = hcmDesynced;
|
||||
hcmDesynced = false;
|
||||
hcmRemoveBadge();
|
||||
if (wasDesynced) {
|
||||
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
|
||||
}
|
||||
// Resync to the host's current position (retries if host state not yet known).
|
||||
hcmRequestHostSyncWithRetry();
|
||||
reportLog('Host-only: resynced with the host', 'info');
|
||||
}
|
||||
|
||||
function hcmShowBadge() {
|
||||
if (hcmBadgeHost) return;
|
||||
if (!document.body) {
|
||||
// Body not ready yet (very early injection). Defer until DOMReady,
|
||||
// otherwise the desynced user silently never sees the badge (L-4).
|
||||
if (!hcmBadgePending) {
|
||||
hcmBadgePending = true;
|
||||
const retry = () => {
|
||||
hcmBadgePending = false;
|
||||
if (hcmDesynced && !hcmBadgeHost) hcmShowBadge();
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', retry, { once: true });
|
||||
} else {
|
||||
setTimeout(retry, 50);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
|
||||
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
|
||||
b.addEventListener('click', hcmExitDesync);
|
||||
root.appendChild(b);
|
||||
document.body.appendChild(host);
|
||||
hcmBadgeHost = host;
|
||||
}
|
||||
|
||||
function hcmRemoveBadge() {
|
||||
if (hcmBadgeHost) { hcmBadgeHost.remove(); hcmBadgeHost = null; }
|
||||
}
|
||||
|
||||
function hcmReset() {
|
||||
const wasDesynced = hcmDesynced;
|
||||
hcmDesynced = false;
|
||||
hcmDeferredSnapPending = false;
|
||||
hcmSnapBackCooldownUntil = 0; // don't let a stale cooldown swallow the next snap-back
|
||||
hcmBufferingUntil = 0;
|
||||
hcmRemoveDialog();
|
||||
hcmRemoveBadge();
|
||||
// If we were desynced, notify background so it stops reporting us as
|
||||
// desynced in heartbeats (otherwise the host's UI keeps showing the
|
||||
// stale Solo badge until the next state change).
|
||||
if (wasDesynced) {
|
||||
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function reportLog(message, level = 'info') {
|
||||
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
|
||||
}
|
||||
@@ -114,6 +443,149 @@
|
||||
return best;
|
||||
}
|
||||
|
||||
// --- Audio Processing Module ---
|
||||
const AUDIO_PRESETS = {
|
||||
recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 },
|
||||
dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 },
|
||||
vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 },
|
||||
smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 },
|
||||
custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 }
|
||||
};
|
||||
const DEFAULT_AUDIO_SETTINGS = {
|
||||
enabled: false,
|
||||
compressor: {
|
||||
enabled: false,
|
||||
preset: 'recommended',
|
||||
customParams: { ...AUDIO_PRESETS.custom }
|
||||
}
|
||||
};
|
||||
let audioCtx = null;
|
||||
let audioChains = new WeakMap();
|
||||
let currentAudioVideo = null;
|
||||
|
||||
function mergeAudioSettings(settings = {}) {
|
||||
const safeSettings = settings && typeof settings === 'object' ? settings : {};
|
||||
return {
|
||||
...DEFAULT_AUDIO_SETTINGS,
|
||||
...safeSettings,
|
||||
compressor: {
|
||||
...DEFAULT_AUDIO_SETTINGS.compressor,
|
||||
...(safeSettings.compressor || {}),
|
||||
customParams: {
|
||||
...DEFAULT_AUDIO_SETTINGS.compressor.customParams,
|
||||
...(safeSettings.compressor?.customParams || {})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function initAudioContext() {
|
||||
if (!audioCtx) {
|
||||
try {
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass) return null;
|
||||
audioCtx = new AudioContextClass({ latencyHint: 'interactive' });
|
||||
} catch (e) {
|
||||
reportLog(`Audio Processing unavailable: ${e.message}`, 'warn');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume().catch(() => { reportLog('AudioContext resume failed - browser may need page interaction first', 'warn'); });
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
function closeAudioContext() {
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {});
|
||||
audioCtx = null;
|
||||
}
|
||||
audioChains = new WeakMap();
|
||||
currentAudioVideo = null;
|
||||
}
|
||||
|
||||
function setupAudioChain(videoEl) {
|
||||
if (audioChains.has(videoEl)) return audioChains.get(videoEl);
|
||||
const ctx = initAudioContext();
|
||||
if (!ctx) return null;
|
||||
|
||||
try {
|
||||
const src = ctx.createMediaElementSource(videoEl);
|
||||
const compressor = ctx.createDynamicsCompressor();
|
||||
const dryGain = ctx.createGain();
|
||||
const compGain = ctx.createGain();
|
||||
|
||||
src.connect(dryGain);
|
||||
dryGain.connect(ctx.destination);
|
||||
src.connect(compressor);
|
||||
compressor.connect(compGain);
|
||||
compGain.connect(ctx.destination);
|
||||
|
||||
dryGain.gain.value = 1;
|
||||
compGain.gain.value = 0;
|
||||
|
||||
const chain = { compressor, dryGain, compGain, active: false };
|
||||
audioChains.set(videoEl, chain);
|
||||
currentAudioVideo = videoEl;
|
||||
return chain;
|
||||
} catch (e) {
|
||||
reportLog(`Audio Processing setup failed: ${e.message}`, 'warn');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rampGain(node, value, t) {
|
||||
const current = node.gain.value;
|
||||
node.gain.cancelScheduledValues(t);
|
||||
node.gain.setValueAtTime(current, t);
|
||||
node.gain.linearRampToValueAtTime(value, t + 0.04);
|
||||
}
|
||||
|
||||
function applyAudioBypass(videoEl) {
|
||||
const chain = audioChains.get(videoEl);
|
||||
if (!chain || !chain.active) return;
|
||||
const t = chain.dryGain.context.currentTime;
|
||||
rampGain(chain.dryGain, 1, t);
|
||||
rampGain(chain.compGain, 0, t);
|
||||
chain.active = false;
|
||||
reportLog('Audio compressor disabled', 'info');
|
||||
}
|
||||
|
||||
function bypassCurrentAudioProcessing() {
|
||||
if (currentAudioVideo) applyAudioBypass(currentAudioVideo);
|
||||
}
|
||||
|
||||
function applyAudioSettings(videoEl, settings) {
|
||||
const mergedSettings = mergeAudioSettings(settings);
|
||||
if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) {
|
||||
applyAudioBypass(videoEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const chain = setupAudioChain(videoEl);
|
||||
if (!chain) return;
|
||||
|
||||
const cSettings = mergedSettings.compressor;
|
||||
const params = cSettings.preset === 'custom'
|
||||
? cSettings.customParams
|
||||
: AUDIO_PRESETS[cSettings.preset] || AUDIO_PRESETS.recommended;
|
||||
|
||||
chain.compressor.threshold.value = params.threshold ?? -24;
|
||||
chain.compressor.knee.value = params.knee ?? 15;
|
||||
chain.compressor.ratio.value = params.ratio ?? 8;
|
||||
chain.compressor.attack.value = params.attack ?? 0.010;
|
||||
chain.compressor.release.value = params.release ?? 0.300;
|
||||
|
||||
if (!chain.active) {
|
||||
const t = chain.dryGain.context.currentTime;
|
||||
rampGain(chain.dryGain, 0, t);
|
||||
rampGain(chain.compGain, 1, t);
|
||||
chain.active = true;
|
||||
reportLog('Audio compressor enabled', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
function getMediaTitle() {
|
||||
return (navigator.mediaSession && navigator.mediaSession.metadata)
|
||||
@@ -124,28 +596,27 @@
|
||||
// Extract a canonical episode identifier from a title string.
|
||||
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
|
||||
// Returns null if no episode pattern found.
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_START ---
|
||||
// This block is automatically replaced by /scripts/build-extension.js
|
||||
function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
// S01E01 patterns (with optional spaces, dashes, dots between S and E)
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
|
||||
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')}`;
|
||||
// "Episode X", "Folge X", "Ep. X", "#X"
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns true if two titles likely refer to the same episode.
|
||||
// Strict: both must have IDs and match, OR neither has IDs and exact match.
|
||||
function sameEpisode(titleA, titleB) {
|
||||
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
|
||||
if (!titleA || !titleB) return false; // One unknown, one known → different
|
||||
if (!titleA && !titleB) return true;
|
||||
if (!titleA || !titleB) return false;
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
|
||||
if (idA || idB) return false; // One has ID, other doesn't → different
|
||||
return titleA === titleB; // Neither has ID → exact string match
|
||||
if (idA && idB) return idA === idB;
|
||||
if (idA || idB) return false;
|
||||
return titleA === titleB;
|
||||
}
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_END ---
|
||||
|
||||
// Returns true only when we are CERTAIN the episodes differ.
|
||||
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
|
||||
@@ -185,6 +656,10 @@
|
||||
|
||||
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
|
||||
|
||||
// EC-12: a new episode dissolves any solo/desync state — the guest rejoins
|
||||
// the room for the fresh content rather than staying stuck on the old one.
|
||||
if (hcmDesynced) hcmReset();
|
||||
|
||||
// Do NOT pause here. We notify background.js first.
|
||||
// Background checks the setting; if enabled it creates a lobby
|
||||
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
|
||||
@@ -273,7 +748,7 @@
|
||||
ytButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -289,7 +764,7 @@
|
||||
twitchButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -307,7 +782,7 @@
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -344,16 +819,79 @@
|
||||
|
||||
// Listen for commands from background.js
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message) return;
|
||||
if (message.action === 'get_current_time') {
|
||||
const video = findVideo();
|
||||
sendResponse({ currentTime: video ? video.currentTime : null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === 'APPLY_AUDIO_SETTINGS') {
|
||||
_audioProcessingAllowed = true;
|
||||
_audioSettings = mergeAudioSettings(message.settings);
|
||||
const video = findVideo();
|
||||
if (video) applyAudioSettings(video, _audioSettings);
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === 'RESET_AUDIO_PROCESSING') {
|
||||
_audioProcessingAllowed = false;
|
||||
bypassCurrentAudioProcessing();
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Host Control Mode: room mode/role changed.
|
||||
if (message.type === 'CONTROL_MODE') {
|
||||
const wasGated = hcmIsGuestGated();
|
||||
const prevHostPeerId = hcmHostPeerId;
|
||||
hcmControlMode = message.controlMode || 'everyone';
|
||||
hcmAmController = !!message.amController;
|
||||
hcmHostPeerId = message.hostPeerId || null;
|
||||
// Reset guest-side state when leaving the gated state, OR when the
|
||||
// host identity changes (room switch, host-leave fallback, missed
|
||||
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
|
||||
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
|
||||
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Host Control Mode: background blocked our local action — handle UX locally.
|
||||
if (message.type === 'HOST_BLOCKED') {
|
||||
hcmHandleBlocked(message.action, message.target || null);
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Background asks for an immediate state push (e.g. the first peer just
|
||||
// joined while we were solo) so the newcomer syncs without waiting.
|
||||
if (message.type === 'REQUEST_HEARTBEAT') {
|
||||
sendHeartbeat();
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'SERVER_COMMAND') {
|
||||
const { action, payload } = message;
|
||||
let actionCompleted = false;
|
||||
|
||||
// Host Control Mode: while watching on our own (desynced), don't apply
|
||||
// host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's
|
||||
// force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK
|
||||
// is intentional so the host's UI honestly reflects that we didn't apply
|
||||
// the command (M-2); sending them would make the host think we're synced.
|
||||
if (hcmDesynced) {
|
||||
const soloIgnored = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
|
||||
if (soloIgnored.includes(action)) {
|
||||
if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: Don't execute sync commands if peers are on different episodes.
|
||||
// Only active when autoSyncNextEpisode setting is enabled (default: on).
|
||||
// Only blocks when BOTH sides have parseable S01E01-style IDs that differ.
|
||||
@@ -366,7 +904,7 @@
|
||||
if (isDifferentEpisode(senderTitle, myTitle)) {
|
||||
reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn');
|
||||
if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -393,9 +931,13 @@
|
||||
return;
|
||||
}
|
||||
_setSuppress('paused');
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = payload.targetTime;
|
||||
video.pause();
|
||||
video.currentTime = payload.targetTime;
|
||||
try {
|
||||
video.currentTime = payload.targetTime;
|
||||
} catch (e) {
|
||||
reportLog(`Force Sync Seek Error: ${e.message}`, 'error');
|
||||
}
|
||||
pollSeekReady(payload.targetTime).then((ready) => {
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
|
||||
if (ready) {
|
||||
@@ -419,6 +961,15 @@
|
||||
|
||||
// Episode Auto-Sync: Lobby notification from background
|
||||
if (message.type === 'EPISODE_LOBBY') {
|
||||
// Host Control Mode: a desynced guest is watching on their own and must
|
||||
// not join the lobby flow. Otherwise they'd pause on title match, report
|
||||
// ready, but then ignore the host's FORCE_SYNC_* (hcmDesynced skip in
|
||||
// SERVER_COMMAND) and end up frozen in pause. They also can't be counted
|
||||
// toward lobby completion (background filters them out).
|
||||
if (hcmDesynced) {
|
||||
sendResponse({ status: 'ignored_desynced' });
|
||||
return true;
|
||||
}
|
||||
const expectedTitle = message.expectedTitle;
|
||||
if (expectedTitle) {
|
||||
reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info');
|
||||
@@ -582,6 +1133,46 @@
|
||||
});
|
||||
|
||||
// Detect native events
|
||||
// Build the relay payload from the *current* media state and send it to
|
||||
// background.js. Re-reads the video each call so deferred (coalesced) emits
|
||||
// carry an up-to-date position. Safe with no/invalid video — it no-ops.
|
||||
function sendContentEvent(action) {
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
action,
|
||||
payload: {
|
||||
currentTime: current,
|
||||
targetTime: current,
|
||||
mediaTitle: mediaTitle,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Trigger proactive heartbeat to push stabilized state
|
||||
scheduleProactiveHeartbeat();
|
||||
}
|
||||
|
||||
// Trailing-edge flush of a coalesced play/pause burst: emit the final settled
|
||||
// state. No-ops only when no burst followed the leading edge. We do NOT skip
|
||||
// when the state matches the leading send — a remote command may have changed
|
||||
// the shared state mid-window, so re-sending is the safe (idempotent) choice.
|
||||
function flushPlayPause() {
|
||||
playPauseCoalesceTimer = null;
|
||||
const finalAction = pendingPlayPauseAction;
|
||||
pendingPlayPauseAction = null;
|
||||
// No burst follow-up (only the leading edge fired), or invalid state.
|
||||
if (finalAction !== EVENTS.PLAY && finalAction !== EVENTS.PAUSE) return;
|
||||
sendContentEvent(finalAction);
|
||||
}
|
||||
|
||||
function reportEvent(action) {
|
||||
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
|
||||
clearTimeout(seekDebounceTimer);
|
||||
@@ -600,10 +1191,11 @@
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||
|
||||
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
||||
|
||||
|
||||
// Echo-suppression for remotely-applied commands. MUST stay synchronous
|
||||
// on event arrival: the suppress timer is short-lived (300ms) and would
|
||||
// expire if deferred, leaking an echo back to peers.
|
||||
if (_suppressTimers[eventState]) {
|
||||
_clearSuppress(eventState);
|
||||
return;
|
||||
@@ -612,20 +1204,29 @@
|
||||
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
|
||||
// Play/Pause pass through — user may want to immediately pause after tabbing back.
|
||||
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
action,
|
||||
payload: {
|
||||
currentTime: current,
|
||||
targetTime: current,
|
||||
mediaTitle: mediaTitle,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger proactive heartbeat to push stabilized state
|
||||
scheduleProactiveHeartbeat();
|
||||
// Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The
|
||||
// synchronous gates above have already run; only the network emit is
|
||||
// governed here. Leading edge sends the first event instantly; further
|
||||
// events within the window are collapsed to the final state by the
|
||||
// trailing flush. A pending burst is REPLACED, never dropped — the last
|
||||
// event in the window always wins.
|
||||
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
|
||||
if (playPauseCoalesceTimer === null) {
|
||||
// Window closed → fresh action. Emit now (zero added latency).
|
||||
sendContentEvent(action);
|
||||
pendingPlayPauseAction = null;
|
||||
} else {
|
||||
// Window open → part of a burst. Defer to the trailing flush.
|
||||
pendingPlayPauseAction = action;
|
||||
clearTimeout(playPauseCoalesceTimer);
|
||||
}
|
||||
playPauseCoalesceTimer = setTimeout(flushPlayPause, PLAY_PAUSE_COALESCE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// SEEK (and any non play/pause action): emit immediately.
|
||||
sendContentEvent(action);
|
||||
}
|
||||
|
||||
// --- Tab Visibility Handling ---
|
||||
@@ -650,7 +1251,19 @@
|
||||
});
|
||||
|
||||
// Reset on page hide/show (bfcache, tab discard)
|
||||
window.addEventListener('pagehide', () => { pageVisible = false; });
|
||||
window.addEventListener('pagehide', () => {
|
||||
pageVisible = false;
|
||||
closeAudioContext();
|
||||
if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; }
|
||||
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
|
||||
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
|
||||
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
|
||||
// Drop any pending coalesced play/pause: we are tearing down and will
|
||||
// disconnect — peers learn we are gone via PEER_STATUS 'left'. This also
|
||||
// intentionally suppresses the teardown play/pause burst at the source.
|
||||
if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; pendingPlayPauseAction = null; }
|
||||
observer.disconnect();
|
||||
});
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
// event.persisted is true ONLY when restored from bfcache, not on initial load
|
||||
if (event.persisted && !pageVisible) {
|
||||
@@ -662,6 +1275,9 @@
|
||||
|
||||
const handlePlay = () => reportEvent(EVENTS.PLAY);
|
||||
const handlePause = () => reportEvent(EVENTS.PAUSE);
|
||||
// Host Control Mode: a 'waiting' (buffering) event opens a grace window so the
|
||||
// pause it may trigger isn't misread as a deliberate guest action (EC-1).
|
||||
const handleWaiting = () => { hcmBufferingUntil = Date.now() + HCM_BUFFERING_GRACE_MS; };
|
||||
|
||||
// Seek filtering: ignore HLS/DASH buffering micro-seeks.
|
||||
// Only relay if delta >= MIN_SEEK_DELTA AND not already debouncing.
|
||||
@@ -671,11 +1287,17 @@
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
|
||||
if (_suppressTimers['seek']) {
|
||||
_clearSuppress('seek');
|
||||
lastReportedSeekTime = current;
|
||||
return;
|
||||
// Step 1: Check expectedSeekTime (programmatic seek from remote peer)
|
||||
if (expectedSeekTime !== null) {
|
||||
if (Math.abs(current - expectedSeekTime) < 1.0) {
|
||||
// Video arrived at expected time. Safely clear and ignore.
|
||||
expectedSeekTime = null;
|
||||
lastReportedSeekTime = current;
|
||||
return;
|
||||
} else {
|
||||
// User manually scrubbed to a DIFFERENT time while we were buffering
|
||||
expectedSeekTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
|
||||
@@ -717,25 +1339,32 @@
|
||||
function setupListeners() {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
if (currentAudioVideo && currentAudioVideo !== video) {
|
||||
bypassCurrentAudioProcessing();
|
||||
}
|
||||
const existing = video._koalaHandlers;
|
||||
if (existing) {
|
||||
video.removeEventListener('play', existing.play);
|
||||
video.removeEventListener('pause', existing.pause);
|
||||
video.removeEventListener('seeked', existing.seeked);
|
||||
video.removeEventListener('loadeddata', existing.loadeddata);
|
||||
if (existing.waiting) video.removeEventListener('waiting', existing.waiting);
|
||||
}
|
||||
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData };
|
||||
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData, waiting: handleWaiting };
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('seeked', handleSeeked);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.addEventListener('waiting', handleWaiting);
|
||||
video.dataset.koalaAttached = 'true';
|
||||
lastVideoSrc = video.currentSrc || video.src || null;
|
||||
|
||||
if (!lastKnownMediaTitle) {
|
||||
lastKnownMediaTitle = getMediaTitle();
|
||||
}
|
||||
|
||||
if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +1379,7 @@
|
||||
if (!video && lastVideoSrc !== undefined) {
|
||||
reportLog('Video element removed from page', 'warn');
|
||||
lastVideoSrc = undefined;
|
||||
closeAudioContext();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -862,4 +1492,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Host Control Mode: fetch current room mode/role on injection (we may have
|
||||
// been injected after ROOM_DATA already arrived, missing the broadcast).
|
||||
chrome.runtime.sendMessage({ type: 'GET_CONTROL_MODE' }, (res) => {
|
||||
if (chrome.runtime.lastError || !res) return;
|
||||
hcmControlMode = res.controlMode || 'everyone';
|
||||
hcmAmController = !!res.amController;
|
||||
hcmHostPeerId = res.hostPeerId || null;
|
||||
// Re-adopt persisted desync after a page reload so we don't start synced
|
||||
// while background still relays us as "Solo" to the host (split-brain).
|
||||
// Only when we're actually a gated guest — never adopt a stale flag as a
|
||||
// controller or in 'everyone' mode (would self-label "Solo" / ignore commands).
|
||||
if (res.desynced && res.controlMode === 'host-only' && !res.amController && !hcmDesynced) {
|
||||
hcmDesynced = true;
|
||||
hcmShowBadge();
|
||||
}
|
||||
});
|
||||
|
||||
// Pull localized strings for the in-page dialog/badge (English fallback above).
|
||||
chrome.runtime.sendMessage({ type: 'GET_HCM_STRINGS' }, (res) => {
|
||||
if (chrome.runtime.lastError || !res) return;
|
||||
Object.keys(hcmStrings).forEach(k => { if (res[k]) hcmStrings[k] = res[k]; });
|
||||
// If the badge is already showing (early desync), refresh its text in place.
|
||||
// Re-creating the host element nukes the click target mid-poll and can drop a
|
||||
// click that landed between remove() and the re-create (L-4).
|
||||
if (hcmBadgeHost) {
|
||||
const span = hcmBadgeHost.shadowRoot && hcmBadgeHost.shadowRoot.querySelector('span');
|
||||
if (span) span.textContent = '● ' + hcmStrings.badge;
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -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,8 +1,10 @@
|
||||
// extension/i18n.js
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
|
||||
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 = {};
|
||||
const dictionaryCache = {};
|
||||
let currentLanguage = null;
|
||||
|
||||
/**
|
||||
* Resolves, loads, and merges the target language with the English baseline fallback.
|
||||
@@ -11,13 +13,30 @@ let activeDictionary = {};
|
||||
export async function loadLocale(langCode) {
|
||||
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
|
||||
|
||||
if (currentLanguage === resolvedLang && Object.keys(activeDictionary).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dictionaryCache[resolvedLang]) {
|
||||
activeDictionary = dictionaryCache[resolvedLang];
|
||||
currentLanguage = resolvedLang;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load Baseline English
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
const enDict = await enResponse.json();
|
||||
let enDict;
|
||||
if (dictionaryCache[DEFAULT_LANGUAGE]) {
|
||||
enDict = dictionaryCache[DEFAULT_LANGUAGE];
|
||||
} else {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
enDict = await enResponse.json();
|
||||
dictionaryCache[DEFAULT_LANGUAGE] = enDict;
|
||||
}
|
||||
|
||||
if (resolvedLang === DEFAULT_LANGUAGE) {
|
||||
activeDictionary = enDict;
|
||||
currentLanguage = resolvedLang;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,15 +45,27 @@ export async function loadLocale(langCode) {
|
||||
const targetDict = await targetResponse.json();
|
||||
|
||||
// Airtight Fallback Merge: target overrides en, missing elements fallback to en
|
||||
activeDictionary = Object.assign({}, enDict, targetDict);
|
||||
const mergedDict = Object.assign({}, enDict, targetDict);
|
||||
dictionaryCache[resolvedLang] = mergedDict;
|
||||
activeDictionary = mergedDict;
|
||||
currentLanguage = resolvedLang;
|
||||
} catch (err) {
|
||||
console.error('[i18n] Failed to load locale. Defaulting to English:', err);
|
||||
// Fallback directly to static English if fetching fails
|
||||
try {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
activeDictionary = await enResponse.json();
|
||||
let enDict;
|
||||
if (dictionaryCache[DEFAULT_LANGUAGE]) {
|
||||
enDict = dictionaryCache[DEFAULT_LANGUAGE];
|
||||
} else {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
enDict = await enResponse.json();
|
||||
dictionaryCache[DEFAULT_LANGUAGE] = enDict;
|
||||
}
|
||||
activeDictionary = enDict;
|
||||
currentLanguage = DEFAULT_LANGUAGE;
|
||||
} catch (_) {
|
||||
activeDictionary = {};
|
||||
currentLanguage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +77,7 @@ export async function loadLocale(langCode) {
|
||||
* @returns {string} Translated string or the key itself
|
||||
*/
|
||||
export function getMessage(key, placeholders = null) {
|
||||
let msg = activeDictionary[key] || key;
|
||||
let msg = activeDictionary[key] !== undefined ? String(activeDictionary[key]) : key;
|
||||
if (placeholders && typeof placeholders === 'object') {
|
||||
for (const [k, v] of Object.entries(placeholders)) {
|
||||
msg = msg.replace(new RegExp(`{${k}}`, 'g'), v);
|
||||
@@ -87,3 +118,19 @@ export function translateDOM() {
|
||||
el.setAttribute('placeholder', getMessage(key));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and maps the user's system language to the best supported locale.
|
||||
* @returns {string} Supported language code
|
||||
*/
|
||||
export function getSystemLanguage() {
|
||||
const uiLang = (typeof chrome !== 'undefined' && chrome.i18n && chrome.i18n.getUILanguage)
|
||||
? chrome.i18n.getUILanguage()
|
||||
: '';
|
||||
const fullLang = (navigator.language || uiLang || '').toLowerCase();
|
||||
if (fullLang.startsWith('pt-br')) {
|
||||
return 'pt-BR';
|
||||
}
|
||||
const baseLang = fullLang.split('-')[0];
|
||||
return SUPPORTED_LANGUAGES.includes(baseLang) ? baseLang : DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 852 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 10 KiB |
@@ -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",
|
||||
@@ -91,6 +109,8 @@
|
||||
"BTN_REGEN_ID": "Peer-ID neu generieren",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Generiere deine interne ID neu und verbinde dich erneut",
|
||||
"REGEN_ID_DESC": "Verwende dies, wenn du Fehler wegen doppelter Identität siehst.",
|
||||
"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",
|
||||
@@ -142,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...",
|
||||
@@ -179,5 +201,33 @@
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Alle anderen Teilnehmer haben den Raum verlassen",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zeitüberschreitung — nicht alle Teilnehmer haben die Episode geladen",
|
||||
"LOBBY_CANCEL_USER": "Vom Benutzer abgebrochen",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync-Fehler"
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync-Fehler",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Bewerten",
|
||||
"FOOTER_SUPPORT_PROMPT": "Gefällt dir KoalaSync? Hinterlasse eine Bewertung!",
|
||||
"LABEL_AUDIO_PROCESSING": "Audio-Verarbeitung",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Wendet Audioeffekte wie Kompression auf die Videowiedergabe an",
|
||||
"AUDIO_OPEN_SETTINGS": "Öffnen",
|
||||
"NEW_FEATURE_AUDIO": "Neu: Audio-Verarbeitung — probiere den Kompressor aus!",
|
||||
"AUDIO_BACK": "← Zurück",
|
||||
"AUDIO_PAGE_TITLE": "Audio-Einstellungen",
|
||||
"AUDIO_MASTER_TOGGLE": "Audio-Verarbeitung",
|
||||
"AUDIO_COMPRESSOR": "Kompressor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Aktiviert",
|
||||
"AUDIO_PRESET": "Preset",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Empfohlen",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Dynamikumfang",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Sprache verbessern",
|
||||
"AUDIO_PRESET_SMOOTH": "Sanft",
|
||||
"AUDIO_PRESET_CUSTOM": "Benutzerdefiniert",
|
||||
"AUDIO_PARAM_THRESHOLD": "Schwellwert",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizer",
|
||||
"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!"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -91,6 +109,8 @@
|
||||
"BTN_REGEN_ID": "Regenerate Peer ID",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerate your internal ID and reconnect",
|
||||
"REGEN_ID_DESC": "Use this if you see \"Duplicate Identity\" errors.",
|
||||
"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",
|
||||
@@ -142,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...",
|
||||
@@ -179,5 +201,33 @@
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "All other peers left",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — not all peers loaded the episode",
|
||||
"LOBBY_CANCEL_USER": "Cancelled by user",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync Error"
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync Error",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Rate us",
|
||||
"FOOTER_SUPPORT_PROMPT": "Enjoying KoalaSync? Leave a review!",
|
||||
"LABEL_AUDIO_PROCESSING": "Audio Processing",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
|
||||
"AUDIO_OPEN_SETTINGS": "Open",
|
||||
"NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
|
||||
"AUDIO_BACK": "← Back",
|
||||
"AUDIO_PAGE_TITLE": "Audio Settings",
|
||||
"AUDIO_MASTER_TOGGLE": "Audio Processing",
|
||||
"AUDIO_COMPRESSOR": "Compressor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Enabled",
|
||||
"AUDIO_PRESET": "Preset",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Recommended",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
|
||||
"AUDIO_PRESET_SMOOTH": "Smooth",
|
||||
"AUDIO_PRESET_CUSTOM": "Custom",
|
||||
"AUDIO_PARAM_THRESHOLD": "Threshold",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizer",
|
||||
"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!"
|
||||
}
|
||||
|
||||
@@ -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,9 +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": "¿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",
|
||||
@@ -114,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",
|
||||
@@ -126,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!",
|
||||
@@ -134,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...",
|
||||
@@ -149,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",
|
||||
@@ -179,5 +201,33 @@
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Todos los demás participantes se han ido",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tiempo de espera agotado: no todos los participantes cargaron el episodio",
|
||||
"LOBBY_CANCEL_USER": "Cancelado por el usuario",
|
||||
"NOTIF_ERROR_TITLE": "Error de KoalaSync"
|
||||
"NOTIF_ERROR_TITLE": "Error de KoalaSync",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Valorar",
|
||||
"FOOTER_SUPPORT_PROMPT": "¿Te gusta KoalaSync? ¡Deja una reseña!",
|
||||
"LABEL_AUDIO_PROCESSING": "Procesamiento de audio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efectos de audio como compresión a la reproducción de video",
|
||||
"AUDIO_OPEN_SETTINGS": "Abrir",
|
||||
"NEW_FEATURE_AUDIO": "Nuevo: Procesamiento de audio — ¡prueba el compresor!",
|
||||
"AUDIO_BACK": "← Volver",
|
||||
"AUDIO_PAGE_TITLE": "Configuración de audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Procesamiento de audio",
|
||||
"AUDIO_COMPRESSOR": "Compresor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Activado",
|
||||
"AUDIO_PRESET": "Preajuste",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Rango dinámico",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Mejora de voz",
|
||||
"AUDIO_PRESET_SMOOTH": "Suave",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizado",
|
||||
"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í!"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -91,6 +109,8 @@
|
||||
"BTN_REGEN_ID": "Régénérer l'identifiant",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Régénérer votre identifiant interne et vous reconnecter",
|
||||
"REGEN_ID_DESC": "Utilisez cette option si vous rencontrez des erreurs de 'Double identité'.",
|
||||
"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é",
|
||||
@@ -142,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...",
|
||||
@@ -179,5 +201,33 @@
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Tous les autres membres sont partis",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Délai dépassé — tous les membres n'ont pas chargé l'épisode",
|
||||
"LOBBY_CANCEL_USER": "Annulé par l'utilisateur",
|
||||
"NOTIF_ERROR_TITLE": "Erreur KoalaSync"
|
||||
"NOTIF_ERROR_TITLE": "Erreur KoalaSync",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Évaluer",
|
||||
"FOOTER_SUPPORT_PROMPT": "Tu aimes KoalaSync? Laisse un avis!",
|
||||
"LABEL_AUDIO_PROCESSING": "Traitement audio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applique des effets audio comme la compression à la lecture vidéo",
|
||||
"AUDIO_OPEN_SETTINGS": "Ouvrir",
|
||||
"NEW_FEATURE_AUDIO": "Nouveau : Traitement audio — essayez le compresseur !",
|
||||
"AUDIO_BACK": "← Retour",
|
||||
"AUDIO_PAGE_TITLE": "Paramètres audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Traitement audio",
|
||||
"AUDIO_COMPRESSOR": "Compresseur",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Activé",
|
||||
"AUDIO_PRESET": "Préréglage",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Recommandé",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Plage dynamique",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Amélioration vocale",
|
||||
"AUDIO_PRESET_SMOOTH": "Douce",
|
||||
"AUDIO_PRESET_CUSTOM": "Personnalisé",
|
||||
"AUDIO_PARAM_THRESHOLD": "Seuil",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Égaliseur",
|
||||
"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 !"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Stanza",
|
||||
"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",
|
||||
"TAB_STATUS": "Stato",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnostica avanzata e log",
|
||||
"BTN_CREATE_ROOM": "+ Crea Nuova Stanza",
|
||||
"MANUAL_CONNECT_HEADER": "Connessione Manuale / Avanzata",
|
||||
"LABEL_SERVER": "Server",
|
||||
"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 privato",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://tuo-server:3000",
|
||||
"LABEL_ROOM_ID": "ID Stanza",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "L'identificatore univoco per la tua stanza",
|
||||
"PLACEHOLDER_ROOM_ID": "Inserisci ID Stanza",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID della stanza a cui vuoi unirti",
|
||||
"LABEL_PASSWORD": "Password (Opzionale)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Password per limitare l'accesso alla stanza",
|
||||
"PLACEHOLDER_PASSWORD": "Password della stanza (opzionale)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Lascia vuoto se non c'è una password",
|
||||
"BTN_JOIN_ROOM": "Entra / Crea Stanza",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Entra nella stanza",
|
||||
"LABEL_PUBLIC_ROOMS": "Stanze Pubbliche",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubbliche su questo server",
|
||||
"BTN_REFRESH": "AGGIORNA",
|
||||
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Aggiornamento...",
|
||||
"BTN_REFRESH_COOLDOWN": "ATTENDI {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Riprova tra {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento in corso. Disponibile tra {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Stanza Attiva",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei connesso",
|
||||
"ACTIVE_ROOM_NONE": "NESSUNA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Server Ufficiale",
|
||||
"LABEL_INVITE_LINK": "Link di Invito",
|
||||
"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 con il video da sincronizzare",
|
||||
"OPTION_SELECT_TAB": "-- Seleziona una Scheda --",
|
||||
"LABEL_REMOTE_CONTROL": "Telecomando",
|
||||
"BTN_COPY_INVITE": "📋 Link di Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copia il link di invito",
|
||||
"BTN_PLAY": "▶ Riproduci",
|
||||
"BTN_PLAY_TOOLTIP": "Avvia la riproduzione per tutti",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"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": "SALA D'ATTESA EPISODIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 In attesa di: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "In attesa dei partecipanti...",
|
||||
"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 stanza casuale ed entra",
|
||||
"LABEL_USERNAME": "Tuo Nome Utente",
|
||||
"LABEL_USERNAME_TOOLTIP": "Il tuo nome visibile agli altri.",
|
||||
"PLACEHOLDER_USERNAME": "Koala Anonimo",
|
||||
"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 notifiche quando qualcuno entra, esce o cambia stato.",
|
||||
"LABEL_LANGUAGE": "Lingua Applicazione",
|
||||
"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 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 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",
|
||||
"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 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 tecnici per il debug",
|
||||
"BTN_CLEAR": "PULISCI",
|
||||
"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 ai tuoi amici in perfetta sincronia. Facciamo un breve tour!",
|
||||
"ONBOARDING_2_TITLE": "1. Crea una Stanza",
|
||||
"ONBOARDING_2_TEXT": "Inizia da qui. Crea una stanza e condividi il link con i tuoi amici.",
|
||||
"ONBOARDING_3_TITLE": "2. Seleziona Video",
|
||||
"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 per farti riconoscere dai tuoi amici.",
|
||||
"ONBOARDING_5_TITLE": "Tutto pronto!",
|
||||
"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.",
|
||||
"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": "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} {action}",
|
||||
"STATUS_CONNECTED": "Connesso",
|
||||
"STATUS_RECONNECTING": "Riconnessione...",
|
||||
"STATUS_CONNECTING": "Connessione in corso...",
|
||||
"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": "▶ In riproduzione...",
|
||||
"BTN_STATE_PAUSING": "⏸ In pausa...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione di gruppo ({time})...",
|
||||
"BTN_STATE_SYNCING": "Sincronizzazione...",
|
||||
"BTN_STATE_SYNCED": "✅ Sincronizzato!",
|
||||
"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 appariranno qui",
|
||||
"EMPTY_ROOMS_TITLE": "Nessuna stanza attiva",
|
||||
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna l'elenco",
|
||||
"LABEL_YOU": "Tu",
|
||||
"ONBOARDING_DONE": "Fatto!",
|
||||
"LABEL_LOBBY_PEER_READY": "Pronto",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Caricamento...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Protetto da Password",
|
||||
"LABEL_PEERS_COUNT": "{count} partecipanti",
|
||||
"LABEL_CUSTOM_SERVER": "Server Personalizzato",
|
||||
"BTN_STATE_CREATING": "🚀 Creazione Stanza...",
|
||||
"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": "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 sonori (come il compressore) ai video",
|
||||
"AUDIO_OPEN_SETTINGS": "Apri",
|
||||
"NEW_FEATURE_AUDIO": "Novità: Elaborazione Audio — prova il compressore!",
|
||||
"AUDIO_BACK": "← Indietro",
|
||||
"AUDIO_PAGE_TITLE": "Impostazioni Audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Elaborazione Audio",
|
||||
"AUDIO_COMPRESSOR": "Compressore",
|
||||
"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_SMOOTH": "Morbido",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizzato",
|
||||
"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 introduttivo",
|
||||
"HINT_SELECT_VIDEO": "Scegli il tuo video qui!"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"LANG_CODE": "ja",
|
||||
"HTML_CLASS": "lang-ja",
|
||||
"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": "ルーム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": "ピアIDの再生成",
|
||||
"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": "切断されました",
|
||||
"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": "IDがまだロードされていません。しばらく待ってから再試行してください。",
|
||||
"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": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "イコライザー",
|
||||
"AUDIO_COMING_SOON": "近日公開",
|
||||
"BTN_RESTART_TOUR": "チュートリアルを再起動",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "オンボーディングチュートリアルを再起動する",
|
||||
"HINT_SELECT_VIDEO": "ここで動画を選択してください!"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"LANG_CODE": "ko",
|
||||
"HTML_CLASS": "lang-ko",
|
||||
"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": "방 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": "피어 ID 재생성",
|
||||
"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": "연결 끊김",
|
||||
"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": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"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": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "이퀄라이저",
|
||||
"AUDIO_COMING_SOON": "출시 예정",
|
||||
"BTN_RESTART_TOUR": "튜토리얼 다시 시작",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "온보딩 튜토리얼을 다시 시작합니다",
|
||||
"HINT_SELECT_VIDEO": "여기에서 비디오를 선택하세요!"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"LANG_CODE": "nl",
|
||||
"HTML_CLASS": "lang-nl",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Kamer",
|
||||
"TAB_ROOM_TOOLTIP": "Kamerinstellingen en verbinding",
|
||||
"TAB_SYNC": "Sync",
|
||||
"TAB_SYNC_TOOLTIP": "Videobeheer en externe acties",
|
||||
"TAB_SETTINGS": "Instellingen",
|
||||
"TAB_SETTINGS_TOOLTIP": "Extensievoorkeuren",
|
||||
"TAB_STATUS": "Status",
|
||||
"TAB_STATUS_TOOLTIP": "Geavanceerde diagnostische gegevens & logs",
|
||||
"BTN_CREATE_ROOM": "+ Nieuwe kamer maken",
|
||||
"MANUAL_CONNECT_HEADER": "Handmatig verbinden / Geavanceerd",
|
||||
"LABEL_SERVER": "Server",
|
||||
"BTN_SERVER_OFFICIAL": "Officieel",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Gebruik de officiële betrouwbare server",
|
||||
"BTN_SERVER_CUSTOM": "Aangepast",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Verbind met uw eigen gehoste server",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://uw-server:3000",
|
||||
"LABEL_ROOM_ID": "Kamer-ID",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "De unieke identificatie voor uw synchronisatiekamer",
|
||||
"PLACEHOLDER_ROOM_ID": "Voer kamer-ID in",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Het unieke ID van de kamer waar u aan wilt deelnemen",
|
||||
"LABEL_PASSWORD": "Wachtwoord (optioneel)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Optioneel wachtwoord om toegang tot de kamer te beperken",
|
||||
"PLACEHOLDER_PASSWORD": "Kamerwachtwoord (optioneel)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Wachtwoord voor de kamer (leeg laten indien geen)",
|
||||
"BTN_JOIN_ROOM": "Deelnemen / Kamer maken",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Verbinden met de kamer",
|
||||
"LABEL_PUBLIC_ROOMS": "Openbare kamers",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lijst met openbaar beschikbare kamers op deze server",
|
||||
"BTN_REFRESH": "VERNIEUWEN",
|
||||
"BTN_REFRESH_TOOLTIP": "Vernieuw de lijst met openbare kamers",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Vernieuwen...",
|
||||
"BTN_REFRESH_COOLDOWN": "WACHT {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Vernieuwen van kamerlijst is aan het afkoelen. Probeer opnieuw in {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Openbare kamers vernieuwen. Volgende verversing beschikbaar in {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Actieve kamer",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "De kamer waar u momenteel mee bent verbonden",
|
||||
"ACTIVE_ROOM_NONE": "GEEN",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Officiële server",
|
||||
"LABEL_INVITE_LINK": "Uitnodigingslink",
|
||||
"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",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Kies het browsertabblad met de video die u wilt synchroniseren",
|
||||
"OPTION_SELECT_TAB": "-- Selecteer een tabblad --",
|
||||
"LABEL_REMOTE_CONTROL": "Afstandsbediening",
|
||||
"BTN_COPY_INVITE": "📋 Uitnodigingslink",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Uitnodigingslink kopiëren",
|
||||
"BTN_PLAY": "▶ Afspelen",
|
||||
"BTN_PLAY_TOOLTIP": "Stuur een afspeelopdracht naar iedereen",
|
||||
"BTN_PAUSE": "⏸ Pauzeren",
|
||||
"BTN_PAUSE_TOOLTIP": "Stuur een pauzeeropdracht naar iedereen",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Dwing alle gebruikers om te synchroniseren",
|
||||
"OPTION_JUMP_TO_OTHERS": "Spring naar anderen",
|
||||
"OPTION_JUMP_TO_ME": "Spring naar mij",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Kies synchronisatiedoel",
|
||||
"LABEL_LAST_ACTIVITY": "Laatste activiteitsstatus",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Toont de meest recente afspeel-, pauzeer- of zoekopdracht",
|
||||
"NO_RECENT_COMMANDS": "Geen recente commando's",
|
||||
"LOBBY_HEADER": "EPISODE LOBBY",
|
||||
"LOBBY_WAITING_FOR": "🎬 Wachten op: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Wachten op deelnemers...",
|
||||
"BTN_SKIP_PLAY": "Overslaan & toch afspelen",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Lobby annuleren en toch afspelen",
|
||||
"LOBBY_CONNECT_FIRST": "Verbind eerst met een kamer",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "U moet deelnemen aan een kamer via een uitnodigingslink of een nieuwe maken om video's te synchroniseren.",
|
||||
"BTN_CREATE_ROOM_ALT": "Nieuwe kamer maken",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Maak een nieuwe willekeurige kamer en neem deel",
|
||||
"LABEL_USERNAME": "Uw gebruikersnaam",
|
||||
"LABEL_USERNAME_TOOLTIP": "Gebruikersnaam helpt anderen u te identificeren.",
|
||||
"PLACEHOLDER_USERNAME": "Anonieme Koala",
|
||||
"LABEL_HIDE_CLUTTER": "Overbodige tabbladen verbergen",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtert tabbladen zonder video's en niet-gerelateerde domeinen om de lijst schoon te houden",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Volgende aflevering automatisch syncen",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pauzeert automatisch en wacht op alle deelnemers wanneer een aflevering verandert, en start dan gezamenlijk gesynchroniseerd.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Uitnodigingslink automatisch kopiëren",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Kopieert de uitnodigingslink automatisch naar uw klembord bij het maken van een nieuwe kamer.",
|
||||
"LABEL_NOTIFICATIONS": "Browsernotificaties",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Toont systeemnotificaties wanneer iemand deelneemt/verlaat of afspeelt/pauzeert.",
|
||||
"LABEL_LANGUAGE": "Taal app",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Kies uw favoriete taal voor de extensie",
|
||||
"LABEL_TROUBLESHOOTING": "Problemen oplossen",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Hulpmiddelen voor het oplossen van verbindingsproblemen",
|
||||
"BTN_REGEN_ID": "Peer-ID opnieuw genereren",
|
||||
"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",
|
||||
"BTN_RETRY": "OPNIEUW PROBEREN",
|
||||
"BTN_RETRY_TOOLTIP": "Proberen opnieuw verbinding te maken met de server",
|
||||
"BTN_COPY_LOGS": "Logs kopiëren",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Kopieer logboeken naar het klembord om te delen",
|
||||
"LABEL_VIDEO_DEBUG": "Video-debuginfo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Technische details over het momenteel geselecteerde video-element",
|
||||
"VIDEO_DEBUG_EMPTY": "Geen tabblad geselecteerd of video gedetecteerd.",
|
||||
"LABEL_HISTORY": "Volledige actiegeschiedenis",
|
||||
"LABEL_HISTORY_TOOLTIP": "Chronologisch logboek van alle synchronisatieopdrachten in de kamer",
|
||||
"HISTORY_EMPTY": "Nog geen activiteit",
|
||||
"LABEL_LOGS": "Logs (Laatste 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Technische verbindingslogboeken voor debuggen",
|
||||
"BTN_CLEAR": "WISSEN",
|
||||
"BTN_CLEAR_TOOLTIP": "Logboekuitvoer wissen",
|
||||
"LABEL_GITHUB": "GitHub-repository",
|
||||
"BTN_ONBOARDING_SKIP": "Overslaan",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Sla de tutorial over",
|
||||
"BTN_ONBOARDING_NEXT": "Volgende",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ga naar de volgende stap",
|
||||
"ONBOARDING_1_TITLE": "Welkom bij KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Bekijk video's samen in perfecte synchronisatie — waar u ook bent. Laten we een snelle rondleiding nemen!",
|
||||
"ONBOARDING_2_TITLE": "1. Kamer maken",
|
||||
"ONBOARDING_2_TEXT": "Begin hier. Maak een kamer en deel de uitnodigingslink met uw vrienden.",
|
||||
"ONBOARDING_3_TITLE": "2. Selecteer video",
|
||||
"ONBOARDING_3_TEXT": "Navigeer hiernaartoe om de video te selecteren die u wilt synchroniseren. Spelen, pauzeren en zoeken — iedereen blijft synchroon.",
|
||||
"ONBOARDING_4_TITLE": "3. Personaliseren",
|
||||
"ONBOARDING_4_TEXT": "Kies een leuke gebruikersnaam zodat uw vrienden weten wie u bent.",
|
||||
"ONBOARDING_5_TITLE": "U bent er helemaal klaar voor!",
|
||||
"ONBOARDING_5_TEXT": "Tijd voor popcorn. Veel plezier met samen kijken!",
|
||||
"ERR_CONN_TIMEOUT": "Verbinding time-out. Probeer het opnieuw.",
|
||||
"ERR_INVALID_SERVER_URL": "Ongeldig server URL-formaat.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.",
|
||||
"ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.",
|
||||
"ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.",
|
||||
"ERR_SELECT_VIDEO": "Selecteer eerst een video!",
|
||||
"TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!",
|
||||
"TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt",
|
||||
"TOAST_LOBBY_SKIPPED": "Episode Lobby overgeslagen.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Lobby overslaan mislukt.",
|
||||
"TOAST_LOGS_COPIED": "Gekopieerd!",
|
||||
"TOAST_PEER_JOINED": "{name} neemt deel aan de kamer",
|
||||
"TOAST_PEER_LEFT": "{name} heeft de kamer verlaten",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Verbonden",
|
||||
"STATUS_RECONNECTING": "Opnieuw verbinden...",
|
||||
"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...",
|
||||
"BTN_STATE_PAUSING": "⏸ Pauzeren...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Synchroniseren met groep ({time})...",
|
||||
"BTN_STATE_SYNCING": "Synchroniseren...",
|
||||
"BTN_STATE_SYNCED": "✅ Gesynchroniseerd!",
|
||||
"NOTIF_PLAY": "is begonnen met afspelen",
|
||||
"NOTIF_PAUSE": "heeft afspelen gepauzeerd",
|
||||
"NOTIF_SEEK": "heeft de video gespoeld",
|
||||
"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 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",
|
||||
"EMPTY_HISTORY_HINT": "Speel af, pauzeer of spoel om geschiedenis te zien",
|
||||
"EMPTY_LOGS_TITLE": "Geen logs",
|
||||
"EMPTY_LOGS_HINT": "Verbindingsgebeurtenissen verschijnen hier",
|
||||
"EMPTY_ROOMS_TITLE": "Geen actieve kamers",
|
||||
"EMPTY_ROOMS_HINT": "Maak een kamer of vernieuw om openbare te vinden",
|
||||
"LABEL_YOU": "Jij",
|
||||
"ONBOARDING_DONE": "Klaar!",
|
||||
"LABEL_LOBBY_PEER_READY": "Gereed",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Laden...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Wachtwoord beveiligd",
|
||||
"LABEL_PEERS_COUNT": "{count} deelnemers",
|
||||
"LABEL_CUSTOM_SERVER": "Aangepaste server",
|
||||
"BTN_STATE_CREATING": "🚀 Kamer maken...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Episode Sync mislukt",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync geannuleerd: {reason}. Mogelijk moet u handmatig synchroniseren.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Time-out",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Time-out (hersteld)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Alle andere deelnemers zijn vertrokken",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Time-out — niet alle deelnemers hebben de aflevering geladen",
|
||||
"LOBBY_CANCEL_USER": "Geannuleerd door gebruiker",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync-fout",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Beoordelen",
|
||||
"FOOTER_SUPPORT_PROMPT": "Vind je KoalaSync leuk? Laat een review achter!",
|
||||
"LABEL_AUDIO_PROCESSING": "Audioverwerking",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Past audio-effecten zoals compressie toe op video-afspelen",
|
||||
"AUDIO_OPEN_SETTINGS": "Openen",
|
||||
"NEW_FEATURE_AUDIO": "Nieuw: Audioverwerking — probeer de compressor!",
|
||||
"AUDIO_BACK": "← Terug",
|
||||
"AUDIO_PAGE_TITLE": "Audio-instellingen",
|
||||
"AUDIO_MASTER_TOGGLE": "Audioverwerking",
|
||||
"AUDIO_COMPRESSOR": "Compressor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Ingeschakeld",
|
||||
"AUDIO_PRESET": "Voorinstelling",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Aanbevolen",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Dynamisch bereik",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Stemverbetering",
|
||||
"AUDIO_PRESET_SMOOTH": "Zacht",
|
||||
"AUDIO_PRESET_CUSTOM": "Aangepast",
|
||||
"AUDIO_PARAM_THRESHOLD": "Drempel",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizer",
|
||||
"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!"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"LANG_CODE": "pl",
|
||||
"HTML_CLASS": "lang-pl",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Pokój",
|
||||
"TAB_ROOM_TOOLTIP": "Ustawienia pokoju i połączenie",
|
||||
"TAB_SYNC": "Sync",
|
||||
"TAB_SYNC_TOOLTIP": "Sterowanie synchronizacją wideo i zdalne akcje",
|
||||
"TAB_SETTINGS": "Ustawienia",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferencje rozszerzenia",
|
||||
"TAB_STATUS": "Status",
|
||||
"TAB_STATUS_TOOLTIP": "Zaawansowana diagnostyka i logi",
|
||||
"BTN_CREATE_ROOM": "+ Utwórz nowy pokój",
|
||||
"MANUAL_CONNECT_HEADER": "Połączenie ręczne / Zaawansowane",
|
||||
"LABEL_SERVER": "Serwer",
|
||||
"BTN_SERVER_OFFICIAL": "Oficjalny",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Użyj oficjalnego, niezawodnego serwera",
|
||||
"BTN_SERVER_CUSTOM": "Własny",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Połącz się z własnym serwerem",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://twoj-serwer:3000",
|
||||
"LABEL_ROOM_ID": "ID pokoju",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "Unikalny identyfikator pokoju synchronizacji",
|
||||
"PLACEHOLDER_ROOM_ID": "Wpisz ID pokoju",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Unikalny identyfikator pokoju, do którego chcesz dołączyć",
|
||||
"LABEL_PASSWORD": "Hasło (opcjonalnie)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Opcjonalne hasło ograniczające dostęp do pokoju",
|
||||
"PLACEHOLDER_PASSWORD": "Hasło pokoju (opcjonalnie)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Hasło do pokoju (pozostaw puste, jeśli brak)",
|
||||
"BTN_JOIN_ROOM": "Dołącz / Utwórz pokój",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Połącz z pokojem",
|
||||
"LABEL_PUBLIC_ROOMS": "Pokoje publiczne",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista publicznie dostępnych pokoi na tym serwerze",
|
||||
"BTN_REFRESH": "ODŚWIEŻ",
|
||||
"BTN_REFRESH_TOOLTIP": "Odśwież listę pokoi publicznych",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Odświeżanie...",
|
||||
"BTN_REFRESH_COOLDOWN": "CZEKAJ {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Odświeżanie listy pokoi stygnie. Spróbuj ponownie za {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Odświeżanie pokoi publicznych. Następne odświeżenie za {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Aktywny pokój",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Pokój, z którym jesteś aktualnie połączony",
|
||||
"ACTIVE_ROOM_NONE": "BRAK",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Oficjalny serwer",
|
||||
"LABEL_INVITE_LINK": "Link do zaproszenia",
|
||||
"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",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Wybierz kartę przeglądarki zawierającą wideo do synchronizacji",
|
||||
"OPTION_SELECT_TAB": "-- Wybierz kartę --",
|
||||
"LABEL_REMOTE_CONTROL": "Zdalne sterowanie",
|
||||
"BTN_COPY_INVITE": "📋 Link do zaproszenia",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Kopiuj link zaproszenia",
|
||||
"BTN_PLAY": "▶ Odtwórz",
|
||||
"BTN_PLAY_TOOLTIP": "Wyślij polecenie odtwarzania do wszystkich",
|
||||
"BTN_PAUSE": "⏸ Pauza",
|
||||
"BTN_PAUSE_TOOLTIP": "Wyślij polecenie pauzy do wszystkich",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Wymuś synchronizację wszystkich użytkowników",
|
||||
"OPTION_JUMP_TO_OTHERS": "Skocz do innych",
|
||||
"OPTION_JUMP_TO_ME": "Skocz do mnie",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Wybierz cel synchronizacji",
|
||||
"LABEL_LAST_ACTIVITY": "Status ostatniej aktywności",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Pokazuje najnowsze polecenie odtwarzania, pauzy lub przewijania",
|
||||
"NO_RECENT_COMMANDS": "Brak ostatnich poleceń",
|
||||
"LOBBY_HEADER": "LOBBY ODCINKA",
|
||||
"LOBBY_WAITING_FOR": "🎬 Czekam na: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Oczekiwanie na uczestników...",
|
||||
"BTN_SKIP_PLAY": "Pomiń i odtwórz mimo to",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Anuluj lobby i odtwórz mimo to",
|
||||
"LOBBY_CONNECT_FIRST": "Najpierw połącz się z pokojem",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Musisz dołączyć do pokoju przez link zaproszenia lub utworzyć nowy, aby synchronizować wideo.",
|
||||
"BTN_CREATE_ROOM_ALT": "Utwórz nowy pokój",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Utwórz nowy losowy pokój i dołącz",
|
||||
"LABEL_USERNAME": "Twoja nazwa użytkownika",
|
||||
"LABEL_USERNAME_TOOLTIP": "Nazwa użytkownika pomaga innym Cię zidentyfikować.",
|
||||
"PLACEHOLDER_USERNAME": "Anonimowy Koala",
|
||||
"LABEL_HIDE_CLUTTER": "Ukryj zbędne karty",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtruje karty bez wideo i niepowiązane domeny, aby zachować porządek",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Auto-sync następnego odcinka",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Automatycznie wstrzymuje i czeka na wszystkich uczestników po zmianie odcinka, a następnie uruchamia się wspólnie.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Auto-kopiowanie zaproszenia",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Automatycznie kopiuje link zaproszenia do schowka po utworzeniu nowego pokoju.",
|
||||
"LABEL_NOTIFICATIONS": "Powiadomienia przeglądarki",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Pokazuje natywne powiadomienia systemowe, gdy ktoś dołącza/odchodzi lub odtwarza/wstrzymuje.",
|
||||
"LABEL_LANGUAGE": "Język aplikacji",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Wybierz preferowany język rozszerzenia",
|
||||
"LABEL_TROUBLESHOOTING": "Rozwiązywanie problemów",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Narzędzia do rozwiązywania problemów z połączeniem",
|
||||
"BTN_REGEN_ID": "Wygeneruj ponownie ID",
|
||||
"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",
|
||||
"BTN_RETRY": "PONÓW",
|
||||
"BTN_RETRY_TOOLTIP": "Próba ponownego połączenia z serwerem",
|
||||
"BTN_COPY_LOGS": "Kopiuj logi",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Kopiuj logi do schowka, aby je udostępnić",
|
||||
"LABEL_VIDEO_DEBUG": "Info debugowania wideo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Szczegóły techniczne wybranego elementu wideo",
|
||||
"VIDEO_DEBUG_EMPTY": "Nie wybrano karty ani nie wykryto wideo.",
|
||||
"LABEL_HISTORY": "Pełna historia akcji",
|
||||
"LABEL_HISTORY_TOOLTIP": "Chronologiczny rejestr wszystkich poleceń synchronizacji w pokoju",
|
||||
"HISTORY_EMPTY": "Brak aktywności",
|
||||
"LABEL_LOGS": "Logi (ostatnie 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Logi połączenia technicznego do debugowania",
|
||||
"BTN_CLEAR": "WYCZYŚĆ",
|
||||
"BTN_CLEAR_TOOLTIP": "Wyczyść dane wyjściowe logów",
|
||||
"LABEL_GITHUB": "Repozytorium GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Pomiń",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Pomiń samouczek",
|
||||
"BTN_ONBOARDING_NEXT": "Dalej",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Przejdź do następnego kroku",
|
||||
"ONBOARDING_1_TITLE": "Witaj w KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Oglądaj filmy w idealnej synchronizacji — bez względu na to, gdzie jesteś. Zróbmy szybką wycieczkę!",
|
||||
"ONBOARDING_2_TITLE": "1. Utwórz pokój",
|
||||
"ONBOARDING_2_TEXT": "Zacznij tutaj. Utwórz pokój i udostępnij link zaproszenia znajomym.",
|
||||
"ONBOARDING_3_TITLE": "2. Wybierz wideo",
|
||||
"ONBOARDING_3_TEXT": "Przejdź tutaj, aby wybrać wideo, które chcesz synchronizować. Odtwarzaj, wstrzymuj i przewijaj — każdy pozostaje zsynchronizowany.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizacja",
|
||||
"ONBOARDING_4_TEXT": "Wybierz fajną nazwę użytkownika, aby znajomi wiedzieli, kim jesteś.",
|
||||
"ONBOARDING_5_TITLE": "Wszystko gotowe!",
|
||||
"ONBOARDING_5_TEXT": "Czas na popcorn. Miłego wspólnego oglądania!",
|
||||
"ERR_CONN_TIMEOUT": "Przekroczono limit czasu połączenia. Spróbuj ponownie.",
|
||||
"ERR_INVALID_SERVER_URL": "Nieprawidłowy format URL serwera.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.",
|
||||
"ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.",
|
||||
"ERR_SELECT_VIDEO": "Najpierw wybierz wideo!",
|
||||
"TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!",
|
||||
"TOAST_COPY_FAILED": "Nie udało się skopiować do schowka",
|
||||
"TOAST_LOBBY_SKIPPED": "Pominięto lobby odcinka.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Nie udało się pominąć lobby.",
|
||||
"TOAST_LOGS_COPIED": "Skopiowano!",
|
||||
"TOAST_PEER_JOINED": "{name} dołączył do pokoju",
|
||||
"TOAST_PEER_LEFT": "{name} opuścił pokój",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Połączono",
|
||||
"STATUS_RECONNECTING": "Ponowne łączenie...",
|
||||
"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...",
|
||||
"BTN_STATE_PAUSING": "⏸ Wstrzymywanie...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Synchronizacja do grupy ({time})...",
|
||||
"BTN_STATE_SYNCING": "Synchronizacja...",
|
||||
"BTN_STATE_SYNCED": "✅ Zsynchronizowano!",
|
||||
"NOTIF_PLAY": "rozpoczął odtwarzanie",
|
||||
"NOTIF_PAUSE": "wstrzymał odtwarzanie",
|
||||
"NOTIF_SEEK": "przewinął wideo",
|
||||
"NOTIF_FORCE_PREPARE": "wymusił synchronizację",
|
||||
"NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich",
|
||||
"DEBUG_NO_TAB": "Nie wybrano karty docelowej.",
|
||||
"DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.",
|
||||
"EMPTY_PEERS_TITLE": "Brak uczestników",
|
||||
"EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć",
|
||||
"EMPTY_HISTORY_TITLE": "Brak aktywności",
|
||||
"EMPTY_HISTORY_HINT": "Odtwórz, wstrzymaj lub przewiń, aby zobaczyć historię",
|
||||
"EMPTY_LOGS_TITLE": "Brak logów",
|
||||
"EMPTY_LOGS_HINT": "Zdarzenia połączenia pojawią się tutaj",
|
||||
"EMPTY_ROOMS_TITLE": "Brak aktywnych pokoi",
|
||||
"EMPTY_ROOMS_HINT": "Utwórz pokój lub odśwież, aby znaleźć publiczne",
|
||||
"LABEL_YOU": "Ty",
|
||||
"ONBOARDING_DONE": "Gotowe!",
|
||||
"LABEL_LOBBY_PEER_READY": "Gotowy",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Ładowanie...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Chronione hasłem",
|
||||
"LABEL_PEERS_COUNT": "{count} uczestników",
|
||||
"LABEL_CUSTOM_SERVER": "Własny serwer",
|
||||
"BTN_STATE_CREATING": "🚀 Tworzenie pokoju...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Synchronizacja odcinka nieudana",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync anulowane: {reason}. Może być konieczna ręczna synchronizacja.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Limit czasu",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Limit czasu (przywrócono)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Wszyscy inni uczestnicy wyszli",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Limit czasu — nie wszyscy uczestnicy załadowali odcinek",
|
||||
"LOBBY_CANCEL_USER": "Anulowane przez użytkownika",
|
||||
"NOTIF_ERROR_TITLE": "Błąd KoalaSync",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Oceń",
|
||||
"FOOTER_SUPPORT_PROMPT": "Podoba Ci się KoalaSync? Zostaw recenzję!",
|
||||
"LABEL_AUDIO_PROCESSING": "Przetwarzanie dźwięku",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Stosuje efekty dźwiękowe, takie jak kompresja, do odtwarzania wideo",
|
||||
"AUDIO_OPEN_SETTINGS": "Otwórz",
|
||||
"NEW_FEATURE_AUDIO": "Nowość: Przetwarzanie dźwięku — wypróbuj kompresor!",
|
||||
"AUDIO_BACK": "← Wstecz",
|
||||
"AUDIO_PAGE_TITLE": "Ustawienia dźwięku",
|
||||
"AUDIO_MASTER_TOGGLE": "Przetwarzanie dźwięku",
|
||||
"AUDIO_COMPRESSOR": "Kompresor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Włączony",
|
||||
"AUDIO_PRESET": "Preset",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Zalecany",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Zakres dynamiki",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Wzmocnienie głosu",
|
||||
"AUDIO_PRESET_SMOOTH": "Płynny",
|
||||
"AUDIO_PRESET_CUSTOM": "Niestandardowy",
|
||||
"AUDIO_PARAM_THRESHOLD": "Próg",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Korektor",
|
||||
"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!"
|
||||
}
|
||||
@@ -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,98 +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'.",
|
||||
"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",
|
||||
@@ -142,42 +162,72 @@
|
||||
"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"
|
||||
"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) 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_COMPRESSOR": "Compressor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Ativado",
|
||||
"AUDIO_PRESET": "Predefinição",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Faixa dinâmica",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Aprimoramento vocal",
|
||||
"AUDIO_PRESET_SMOOTH": "Suave",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizado",
|
||||
"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!"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"LANG_CODE": "pt",
|
||||
"HTML_CLASS": "lang-pt",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Sala",
|
||||
"TAB_ROOM_TOOLTIP": "Definições da sala e ligaçã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 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 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",
|
||||
"PLACEHOLDER_ROOM_ID": "Introduzir ID da Sala",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID da sala à qual deseja aceder",
|
||||
"LABEL_PASSWORD": "Palavra-passe (Opcional)",
|
||||
"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": "Entrar na sala",
|
||||
"LABEL_PUBLIC_ROOMS": "Salas Públicas",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas neste servidor",
|
||||
"BTN_REFRESH": "ATUALIZAR",
|
||||
"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 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 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": "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": "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 Convite",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Convite",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"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": "Última Atividade",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente",
|
||||
"NO_RECENT_COMMANDS": "Nenhum comando recente",
|
||||
"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 sala aleatória e entrar nela",
|
||||
"LABEL_USERNAME": "O seu Nome de Utilizador",
|
||||
"LABEL_USERNAME_TOOLTIP": "O seu nome visível para os outros.",
|
||||
"PLACEHOLDER_USERNAME": "Coala Anónimo",
|
||||
"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 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 Utilizador",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regerar o seu ID interno e voltar a ligar",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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 técnicos para depuração",
|
||||
"BTN_CLEAR": "LIMPAR",
|
||||
"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 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 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 o reconheçam.",
|
||||
"ONBOARDING_5_TITLE": "Está tudo pronto!",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Ligado",
|
||||
"STATUS_RECONNECTING": "A voltar a ligar...",
|
||||
"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": "⏸ 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 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 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",
|
||||
"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 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 Privado",
|
||||
"BTN_STATE_CREATING": "🚀 A Criar Sala...",
|
||||
"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": "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": "Tempo limite — alguns participantes não carregaram o episódio",
|
||||
"LOBBY_CANCEL_USER": "Cancelado pelo utilizador",
|
||||
"NOTIF_ERROR_TITLE": "Erro no KoalaSync",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_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) 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_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_SMOOTH": "Suave",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizado",
|
||||
"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 introdutório",
|
||||
"HINT_SELECT_VIDEO": "Selecione o seu vídeo aqui!"
|
||||
}
|
||||
@@ -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": "Выбрать видео",
|
||||
@@ -91,6 +109,8 @@
|
||||
"BTN_REGEN_ID": "Сбросить Peer ID",
|
||||
"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": "Отключено",
|
||||
@@ -142,6 +162,8 @@
|
||||
"STATUS_CONNECTING": "Подключение...",
|
||||
"STATUS_FAILED": "Ошибка",
|
||||
"STATUS_DISCONNECTED": "Отключено",
|
||||
"STATUS_IDLE": "Готов к подключению",
|
||||
"STATUS_IDLE_TOOLTIP": "KoalaSync готов. Присоединитесь к комнате или создайте её, чтобы подключиться и начать синхронизацию.",
|
||||
"BTN_STATE_JOINING": "🚀 Вход...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Переподключение...",
|
||||
"BTN_STATE_PLAYING": "▶ Запуск...",
|
||||
@@ -179,5 +201,33 @@
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Все остальные участники вышли",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
|
||||
"LOBBY_CANCEL_USER": "Отменено пользователем",
|
||||
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync"
|
||||
"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": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Эквалайзер",
|
||||
"AUDIO_COMING_SOON": "Скоро",
|
||||
"BTN_RESTART_TOUR": "Перезапустить обучение",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Запустить приветственное руководство заново",
|
||||
"HINT_SELECT_VIDEO": "Выберите ваше видео здесь!"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"LANG_CODE": "tr",
|
||||
"HTML_CLASS": "lang-tr",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Oda",
|
||||
"TAB_ROOM_TOOLTIP": "Oda ayarları ve bağlantı",
|
||||
"TAB_SYNC": "Senkronizasyon",
|
||||
"TAB_SYNC_TOOLTIP": "Video senkronizasyon kontrolleri ve uzaktan işlemler",
|
||||
"TAB_SETTINGS": "Ayarlar",
|
||||
"TAB_SETTINGS_TOOLTIP": "Eklenti tercihleri",
|
||||
"TAB_STATUS": "Durum",
|
||||
"TAB_STATUS_TOOLTIP": "Gelişmiş Teşhis ve Günlükler",
|
||||
"BTN_CREATE_ROOM": "+ Yeni Oda Oluştur",
|
||||
"MANUAL_CONNECT_HEADER": "Manuel Bağlantı / Gelişmiş",
|
||||
"LABEL_SERVER": "Sunucu",
|
||||
"BTN_SERVER_OFFICIAL": "Resmi",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Resmi güvenilir sunucuyu kullanın",
|
||||
"BTN_SERVER_CUSTOM": "Özel",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Kendi barındırdığınız sunucuya bağlanın",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://sunucunuz:3000",
|
||||
"LABEL_ROOM_ID": "Oda Kimliği",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "Senkronizasyon odanız için benzersiz kimlik",
|
||||
"PLACEHOLDER_ROOM_ID": "Oda Kimliğini Girin",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Katılmak istediğiniz odanın benzersiz kimliği",
|
||||
"LABEL_PASSWORD": "Şifre (İsteğe Bağlı)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Oda erişimini kısıtlamak için isteğe bağlı şifre",
|
||||
"PLACEHOLDER_PASSWORD": "Oda Şifresi (isteğe bağlı)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Oda şifresi (yoksa boş bırakın)",
|
||||
"BTN_JOIN_ROOM": "Odaya Katıl / Oluştur",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Odaya bağlan",
|
||||
"LABEL_PUBLIC_ROOMS": "Açık Odalar",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Bu sunucuda herkese açık odaların listesi",
|
||||
"BTN_REFRESH": "YENİLE",
|
||||
"BTN_REFRESH_TOOLTIP": "Genel odaların listesini yenile",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Yenileniyor...",
|
||||
"BTN_REFRESH_COOLDOWN": "{seconds}sn BEKLE",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Oda listesi yenileme soğuyor. {seconds}sn sonra tekrar deneyin.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Açık odalar yenileniyor. Bir sonraki yenileme {seconds}sn sonra yapılabilecek.",
|
||||
"LABEL_ACTIVE_ROOM": "Aktif Oda",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Şu anda bağlı olduğunuz oda",
|
||||
"ACTIVE_ROOM_NONE": "YOK",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Resmi Sunucu",
|
||||
"LABEL_INVITE_LINK": "Davet Linki",
|
||||
"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ç",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Senkronize edilecek videoyu içeren tarayıcı sekmesini seçin",
|
||||
"OPTION_SELECT_TAB": "-- Bir Sekme Seçin --",
|
||||
"LABEL_REMOTE_CONTROL": "Uzaktan Kumanda",
|
||||
"BTN_COPY_INVITE": "📋 Davet Linki",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Davet Linkini Kopyala",
|
||||
"BTN_PLAY": "▶ Oynat",
|
||||
"BTN_PLAY_TOOLTIP": "Herkese Oynat komutu gönder",
|
||||
"BTN_PAUSE": "⏸ Duraklat",
|
||||
"BTN_PAUSE_TOOLTIP": "Herkese Duraklat komutu gönder",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Tüm kullanıcıları senkronize etmeye zorla",
|
||||
"OPTION_JUMP_TO_OTHERS": "Diğerlerine Atla",
|
||||
"OPTION_JUMP_TO_ME": "Bana Atla",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Senkronizasyon hedefini seçin",
|
||||
"LABEL_LAST_ACTIVITY": "Son Etkinlik Durumu",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "En son oynatma, duraklatma veya arama komutunu gösterir",
|
||||
"NO_RECENT_COMMANDS": "Son komut yok",
|
||||
"LOBBY_HEADER": "BÖLÜM LOBİSİ",
|
||||
"LOBBY_WAITING_FOR": "🎬 Beklenen: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Bağlantılar bekleniyor...",
|
||||
"BTN_SKIP_PLAY": "Atla ve Yine de Oynat",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Lobiyi iptal et ve yine de oynat",
|
||||
"LOBBY_CONNECT_FIRST": "Önce bir odaya bağlanın",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Videoları senkronize etmek için bir davet bağlantısı aracılığıyla bir odaya katılmanız veya yeni bir oda oluşturmanız gerekir.",
|
||||
"BTN_CREATE_ROOM_ALT": "Yeni Oda Oluştur",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Yeni rastgele bir oda oluşturun ve katılın",
|
||||
"LABEL_USERNAME": "Kullanıcı Adınız",
|
||||
"LABEL_USERNAME_TOOLTIP": "Kullanıcı adı başkalarının sizi tanımasına yardımcı olur.",
|
||||
"PLACEHOLDER_USERNAME": "Anonim Koala",
|
||||
"LABEL_HIDE_CLUTTER": "Gereksiz Sekmeleri Gizle",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Listeyi temiz tutmak için video olmayan sekmeleri ve alakasız alan adlarını filtreler",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sonraki Bölümü Otomatik Eşitle",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Bir bölüm değiştiğinde otomatik olarak duraklatılır ve tüm bağlantıların hazır olmasını bekler, ardından birlikte senkronize olarak başlar.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Davet Linkini Otomatik Kopyala",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Yeni bir oda oluşturulduğunda davet bağlantısını panoya otomatik olarak kopyalar.",
|
||||
"LABEL_NOTIFICATIONS": "Tarayıcı Bildirimleri",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Biri katıldığında/ayrıldığında veya oynattığında/duraklattığında yerel sistem bildirimlerini gösterir.",
|
||||
"LABEL_LANGUAGE": "Uygulama Dili",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Tercih ettiğiniz eklenti dilini seçin",
|
||||
"LABEL_TROUBLESHOOTING": "Sorun Giderme",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Bağlantı sorunlarını giderme araçları",
|
||||
"BTN_REGEN_ID": "Bağlantı Kimliğini Yeniden Oluştur",
|
||||
"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",
|
||||
"BTN_RETRY": "TEKRAR DENE",
|
||||
"BTN_RETRY_TOOLTIP": "Sunucuya yeniden bağlanmayı dene",
|
||||
"BTN_COPY_LOGS": "Günlükleri Kopyala",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Paylaşmak için günlükleri panoya kopyalayın",
|
||||
"LABEL_VIDEO_DEBUG": "Video Hata Ayıklama Bilgisi",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Şu anda seçili video öğesi hakkında teknik ayrıntılar",
|
||||
"VIDEO_DEBUG_EMPTY": "Sekme seçilmedi veya video algılanmadı.",
|
||||
"LABEL_HISTORY": "Tam İşlem Geçmişi",
|
||||
"LABEL_HISTORY_TOOLTIP": "Odadaki tüm senkronizasyon komutlarının kronolojik günlüğü",
|
||||
"HISTORY_EMPTY": "Henüz etkinlik yok",
|
||||
"LABEL_LOGS": "Günlükler (Son 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Hata ayıklama için teknik bağlantı günlükleri",
|
||||
"BTN_CLEAR": "TEMİZLE",
|
||||
"BTN_CLEAR_TOOLTIP": "Günlük çıktısını temizle",
|
||||
"LABEL_GITHUB": "GitHub Deposu",
|
||||
"BTN_ONBOARDING_SKIP": "Atla",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Eğitimi atla",
|
||||
"BTN_ONBOARDING_NEXT": "İleri",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Bir sonraki adıma git",
|
||||
"ONBOARDING_1_TITLE": "KoalaSync'e Hoş Geldiniz!",
|
||||
"ONBOARDING_1_TEXT": "Nerede olursanız olun videoları mükemmel bir senkronizasyonla birlikte izleyin. Hızlı bir tura çıkalım!",
|
||||
"ONBOARDING_2_TITLE": "1. Oda Oluştur",
|
||||
"ONBOARDING_2_TEXT": "Buradan başlayın. Bir oda oluşturun ve davet bağlantısını arkadaşlarınızla paylaşın.",
|
||||
"ONBOARDING_3_TITLE": "2. Video Seç",
|
||||
"ONBOARDING_3_TEXT": "Senkronize etmek istediğiniz videoyu seçmek için buraya gidin. Oynatın, duraklatın ve arayın — herkes senkronize kalır.",
|
||||
"ONBOARDING_4_TITLE": "3. Kişiselleştir",
|
||||
"ONBOARDING_4_TEXT": "Arkadaşlarınızın kim olduğunuzu bilmesi için eğlenceli bir kullanıcı adı seçin.",
|
||||
"ONBOARDING_5_TITLE": "Her şey hazır!",
|
||||
"ONBOARDING_5_TEXT": "Patlamış mısır alma zamanı. Birlikte izlemenin keyfini çıkarın!",
|
||||
"ERR_CONN_TIMEOUT": "Bağlantı zaman aşımına uğradı. Lütfen tekrar deneyin.",
|
||||
"ERR_INVALID_SERVER_URL": "Geçersiz Sunucu URL formatı.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Kimlik henüz yüklenmedi. Biraz bekleyin ve tekrar deneyin.",
|
||||
"ERR_NO_PEERS_TIME": "Bilinen bir zamanı olan başka bağlantı yok. 'Bana Atla' seçeneğine geçin.",
|
||||
"ERR_NO_VIDEO_TAB": "Video sekmesine bağlanılamadı.",
|
||||
"ERR_SELECT_VIDEO": "Lütfen önce bir video seçin!",
|
||||
"TOAST_INVITE_COPIED": "Davet bağlantısı kopyalandı!",
|
||||
"TOAST_COPY_FAILED": "Panoya kopyalanamadı",
|
||||
"TOAST_LOBBY_SKIPPED": "Bölüm Lobisi atlandı.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Lobi atlanamadı.",
|
||||
"TOAST_LOGS_COPIED": "Kopyalandı!",
|
||||
"TOAST_PEER_JOINED": "{name} odaya katıldı",
|
||||
"TOAST_PEER_LEFT": "{name} odadan ayrıldı",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Bağlandı",
|
||||
"STATUS_RECONNECTING": "Yeniden bağlanılıyor...",
|
||||
"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...",
|
||||
"BTN_STATE_PAUSING": "⏸ Duraklatılıyor...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Gruba eşitleniyor ({time})...",
|
||||
"BTN_STATE_SYNCING": "Senkronize ediliyor...",
|
||||
"BTN_STATE_SYNCED": "✅ Eşitlendi!",
|
||||
"NOTIF_PLAY": "oynatmayı başlattı",
|
||||
"NOTIF_PAUSE": "oynatmayı duraklattı",
|
||||
"NOTIF_SEEK": "videoda arama yaptı",
|
||||
"NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı",
|
||||
"NOTIF_FORCE_EXECUTE": "herkesi eşitledi",
|
||||
"DEBUG_NO_TAB": "Hedef sekme seçilmedi.",
|
||||
"DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.",
|
||||
"EMPTY_PEERS_TITLE": "Henüz kimse yok",
|
||||
"EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın",
|
||||
"EMPTY_HISTORY_TITLE": "Henüz etkinlik yok",
|
||||
"EMPTY_HISTORY_HINT": "Geçmişi görmek için oynatın, duraklatın veya arayın",
|
||||
"EMPTY_LOGS_TITLE": "Günlük yok",
|
||||
"EMPTY_LOGS_HINT": "Bağlantı olayları burada görünecektir",
|
||||
"EMPTY_ROOMS_TITLE": "Aktif oda yok",
|
||||
"EMPTY_ROOMS_HINT": "Bir oda oluşturun veya genel odaları bulmak için yenileyin",
|
||||
"LABEL_YOU": "Siz",
|
||||
"ONBOARDING_DONE": "Tamamlandı!",
|
||||
"LABEL_LOBBY_PEER_READY": "Hazır",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Yükleniyor...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Şifre Korumalı",
|
||||
"LABEL_PEERS_COUNT": "{count} kişi",
|
||||
"LABEL_CUSTOM_SERVER": "Özel Sunucu",
|
||||
"BTN_STATE_CREATING": "🚀 Oda Oluşturuluyor...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Bölüm Eşitlemesi Başarısız",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Otomatik eşitleme iptal edildi: {reason}. Manuel olarak eşitlemeniz gerekebilir.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Zaman aşımı",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Zaman aşımı (kurtarıldı)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Diğer tüm bağlantılar ayrıldı",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zaman aşımı — tüm bağlantılar bölümü yüklemedi",
|
||||
"LOBBY_CANCEL_USER": "Kullanıcı tarafından iptal edildi",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync Hatası",
|
||||
"FOOTER_SUPPORT": "Support KoalaSync",
|
||||
"FOOTER_REVIEW": "★ Değerlendir",
|
||||
"FOOTER_SUPPORT_PROMPT": "KoalaSync'i beğendin mi? Bir yorum bırak!",
|
||||
"LABEL_AUDIO_PROCESSING": "Ses işleme",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Video oynatımına sıkıştırma gibi ses efektleri uygular",
|
||||
"AUDIO_OPEN_SETTINGS": "Aç",
|
||||
"NEW_FEATURE_AUDIO": "Yeni: Ses işleme — kompresörü deneyin!",
|
||||
"AUDIO_BACK": "← Geri",
|
||||
"AUDIO_PAGE_TITLE": "Ses ayarları",
|
||||
"AUDIO_MASTER_TOGGLE": "Ses işleme",
|
||||
"AUDIO_COMPRESSOR": "Kompresör",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Etkin",
|
||||
"AUDIO_PRESET": "Ön ayar",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Önerilen",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Dinamik aralık",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Ses iyileştirme",
|
||||
"AUDIO_PRESET_SMOOTH": "Yumuşak",
|
||||
"AUDIO_PRESET_CUSTOM": "Özel",
|
||||
"AUDIO_PARAM_THRESHOLD": "Eşik",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Ekolayzer",
|
||||
"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!"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"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": "Виберіть своє відео тут!"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"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": "在这里选择您的视频!"
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.0.6",
|
||||
"version": "2.4.6",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export function initTabManager({
|
||||
getCurrentTabId,
|
||||
setCurrentTabId,
|
||||
setCurrentTabTitle,
|
||||
setLastContentHeartbeatAt,
|
||||
setRoomIdleSince,
|
||||
getCurrentRoom,
|
||||
getPeerId,
|
||||
getStorageInitialized,
|
||||
updateBadgeStatus,
|
||||
addLog,
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
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') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
})
|
||||
.then(() => applyAudioSettingsToTab(tabId))
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||