Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ad33720053 | |||
| fa3341cc8e | |||
| b526e9287b |
@@ -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,43 @@
|
||||
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:
|
||||
|
||||
# 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
|
||||
|
||||
@@ -40,5 +40,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,102 +0,0 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [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.3-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.6 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.3 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>
|
||||
|
||||
|
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,317 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [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").
|
||||
|
||||
@@ -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.
|
||||
@@ -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,196 @@
|
||||
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() {
|
||||
let audioData = (await chrome.storage.local.get(['audioSettings'])).audioSettings;
|
||||
const syncData = await chrome.storage.sync.get(['audioSettings', 'locale']);
|
||||
if (!audioData && syncData.audioSettings) {
|
||||
audioData = syncData.audioSettings;
|
||||
await chrome.storage.local.set({ audioSettings: audioData });
|
||||
}
|
||||
const lang = syncData.locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
translateDOM();
|
||||
|
||||
currentSettings = mergeAudioSettings(audioData);
|
||||
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);
|
||||
});
|
||||
@@ -1,7 +1,54 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, SUPPORTED_LANGUAGES } from './i18n.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode } from './episode-utils.js';
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
let uninstallURLInitPromise = null;
|
||||
|
||||
async function initUninstallURL() {
|
||||
if (uninstallURLInitPromise) {
|
||||
return uninstallURLInitPromise;
|
||||
}
|
||||
|
||||
uninstallURLInitPromise = (async () => {
|
||||
// --- UNINSTALL_URL_INJECT_START ---
|
||||
const UNINSTALL_URL = ""; // Populated during build
|
||||
const BROWSER_TYPE = "unknown";
|
||||
// --- UNINSTALL_URL_INJECT_END ---
|
||||
|
||||
if (UNINSTALL_URL && UNINSTALL_URL.trim() !== '') {
|
||||
try {
|
||||
const url = new URL(UNINSTALL_URL);
|
||||
url.searchParams.set("browser", BROWSER_TYPE);
|
||||
|
||||
const runtimeAPI = typeof browser !== 'undefined' ? browser.runtime : chrome.runtime;
|
||||
if (runtimeAPI && runtimeAPI.setUninstallURL) {
|
||||
const result = runtimeAPI.setUninstallURL(url.href);
|
||||
// browser.runtime.setUninstallURL returns a Promise, handle rejection silently
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch(err => console.warn('Failed to set uninstall URL:', err));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize uninstall URL:", err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return uninstallURLInitPromise;
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install' || details.reason === 'update') {
|
||||
initUninstallURL();
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
initUninstallURL();
|
||||
});
|
||||
|
||||
// --- State Management ---
|
||||
let socket = null;
|
||||
@@ -16,6 +63,7 @@ let storageInitialized = false;
|
||||
let pendingLogs = [];
|
||||
let pendingHistory = [];
|
||||
let eventQueue = [];
|
||||
let flushTimer = null; // paces draining of eventQueue after (re)connect
|
||||
let isNamespaceJoined = false;
|
||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||
let localSeq = 0; // Monotonically increasing command sequence for this peer
|
||||
@@ -23,6 +71,13 @@ const lastSeqBySender = {}; // senderId → last received seq (sta
|
||||
const activePorts = new Set(); // New: track active content ports for keep-alive
|
||||
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
|
||||
|
||||
// --- Ping / Latency ---
|
||||
let pingInterval = null;
|
||||
let pingTimeout = null;
|
||||
let pendingPingT = null;
|
||||
let currentPingMs = null;
|
||||
let missedPongs = 0;
|
||||
|
||||
// --- Keep-Alive Port Listener ---
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name === 'keepAlive') {
|
||||
@@ -143,9 +198,30 @@ let reconnectAttempts = 0;
|
||||
let currentServerUrl = null;
|
||||
let roomIdleSince = null;
|
||||
let lastContentHeartbeatAt = null;
|
||||
let connectIntent = false;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
// Backoff tuned so that at most ~8 connection attempts land in any 60s window,
|
||||
// keeping a single client comfortably under the server's per-IP connection
|
||||
// budget (10/min) even before jitter. Cumulative (no jitter): 1, 2.8, 6, 11.9,
|
||||
// 22.4, 34.4, 46.4, 58.4s → 8th attempt at ~58s.
|
||||
const _RECONNECT_BASE_DELAY = 1000;
|
||||
const _RECONNECT_MAX_DELAY = 12000;
|
||||
const _RECONNECT_FACTOR = 1.8;
|
||||
const _RECONNECT_GIVEUP_MS = 300000; // switch to slow mode after 5 min of fast retries
|
||||
const _RECONNECT_SLOW_DELAY = 300000; // slow-mode interval: every 5 min
|
||||
const _RECONNECT_JITTER = 0.2; // ±20% randomization to de-synchronize reconnect herds
|
||||
// Paced queue flush: after a (re)connect we drain the offline event backlog in
|
||||
// small batches instead of one synchronous burst, so we stay well under the
|
||||
// server's per-socket event budget (50 / 10s) and leave headroom for the
|
||||
// heartbeats/pings/commands that also count toward it. 10 per 3s ≈ 33/10s.
|
||||
const FLUSH_BATCH_SIZE = 10;
|
||||
const FLUSH_BATCH_INTERVAL_MS = 3000;
|
||||
// Ping liveness: a single unanswered ping is tolerated (transient network
|
||||
// blip); only MAX_MISSED_PONGS consecutive misses force a reconnect. With a
|
||||
// 15s interval and 5s timeout that means ~20s to detect a genuinely dead link.
|
||||
const PING_INTERVAL_MS = 15000;
|
||||
const PING_TIMEOUT_MS = 5000;
|
||||
const MAX_MISSED_PONGS = 2;
|
||||
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Force Sync Coordination
|
||||
@@ -157,26 +233,6 @@ let forceSyncTimeout = null;
|
||||
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
|
||||
let episodeLobbyTimeout = null;
|
||||
|
||||
// --- Episode Title Extraction (synced with content.js) ---
|
||||
function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*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;
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// --- Storage Utils ---
|
||||
|
||||
/**
|
||||
@@ -230,35 +286,38 @@ async function getPeerId() {
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
username = generateUsername();
|
||||
chrome.storage.sync.set({ username }, () => {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username: username
|
||||
});
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username: username
|
||||
});
|
||||
}
|
||||
// Try local (per-device) first, fall back to sync for migration
|
||||
let data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
|
||||
let migrated = false;
|
||||
if (!data.username) {
|
||||
const syncData = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
|
||||
if (syncData.username || syncData.roomId) {
|
||||
data = syncData;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
username = generateUsername();
|
||||
}
|
||||
if (migrated) {
|
||||
await chrome.storage.local.set({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username
|
||||
});
|
||||
});
|
||||
} else if (!data.username) {
|
||||
await chrome.storage.local.set({ username });
|
||||
}
|
||||
return {
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username
|
||||
};
|
||||
}
|
||||
|
||||
function addLog(message, type = 'info') {
|
||||
@@ -296,6 +355,7 @@ function forceDisconnect() {
|
||||
clearTimeout(forceSyncTimeout);
|
||||
forceSyncTimeout = null;
|
||||
}
|
||||
stopPing();
|
||||
if (socket) {
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
@@ -312,6 +372,7 @@ function forceDisconnect() {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
forceSyncAcks.clear();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
@@ -367,7 +428,12 @@ function clearTargetTabForIdle() {
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
@@ -382,7 +448,7 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
lastContentHeartbeatAt: null,
|
||||
episodeLobby: null
|
||||
}).catch(() => {});
|
||||
await chrome.storage.sync.set({ roomId: '', password: '' }).catch(() => {});
|
||||
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
@@ -420,7 +486,9 @@ async function connect() {
|
||||
addLog('Browser is offline. Waiting...', 'warn');
|
||||
broadcastConnectionStatus('offline');
|
||||
isConnecting = false;
|
||||
scheduleReconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -457,7 +525,7 @@ async function connect() {
|
||||
url.pathname = '/socket.io/';
|
||||
url.searchParams.set('EIO', '4');
|
||||
url.searchParams.set('transport', 'websocket');
|
||||
url.searchParams.set('version', APP_VERSION);
|
||||
url.searchParams.set('version', chrome.runtime.getManifest().version);
|
||||
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
|
||||
|
||||
socket = new WebSocket(url.toString());
|
||||
@@ -489,6 +557,7 @@ async function connect() {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = true;
|
||||
broadcastConnectionStatus('connected');
|
||||
startPing();
|
||||
addLog('Joined Namespace /', 'success');
|
||||
const settings = await getSettings();
|
||||
if (settings.roomId) {
|
||||
@@ -501,12 +570,7 @@ async function connect() {
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
while (eventQueue.length > 0) {
|
||||
const queuedMsg = eventQueue.shift();
|
||||
emit(queuedMsg.event, queuedMsg.data);
|
||||
}
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({ eventQueue: [] });
|
||||
flushEventQueue();
|
||||
} else if (msg.startsWith('42')) {
|
||||
try {
|
||||
const payload = JSON.parse(msg.substring(2));
|
||||
@@ -524,26 +588,35 @@ async function connect() {
|
||||
socket.onclose = () => {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
stopPing();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
});
|
||||
if (!connectIntent && !currentRoom) {
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
if (currentRoom) {
|
||||
if (currentRoom && !connectIntent) {
|
||||
currentRoom.peers = [];
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {});
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
}
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog('Disconnected. Scheduling reconnect...', 'warn');
|
||||
socket = null;
|
||||
scheduleReconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
addLog('Disconnected. Scheduling reconnect...', 'warn');
|
||||
socket = null;
|
||||
scheduleReconnect();
|
||||
} else {
|
||||
addLog('Disconnected. No active session — staying disconnected.', 'info');
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
@@ -558,7 +631,9 @@ async function connect() {
|
||||
const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error');
|
||||
addLog(errMsg, logType);
|
||||
broadcastConnectionStatus('disconnected');
|
||||
scheduleReconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,14 +663,10 @@ function updateBadgeStatus() {
|
||||
}
|
||||
|
||||
function showNotification(senderName, action) {
|
||||
chrome.storage.sync.get(['browserNotifications', 'locale'], async (settings) => {
|
||||
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
|
||||
let lang = settings.locale;
|
||||
if (!lang) {
|
||||
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
|
||||
lang = SUPPORTED_LANGUAGES.includes(systemLang) ? systemLang : 'en';
|
||||
}
|
||||
const lang = settings.locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
|
||||
let labelKey = '';
|
||||
@@ -637,17 +708,22 @@ function scheduleReconnect() {
|
||||
const elapsed = Date.now() - reconnectStartTime;
|
||||
reconnectAttempts++;
|
||||
|
||||
if (!reconnectFailed && (elapsed > 300000 || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
|
||||
if (!reconnectFailed && (elapsed > _RECONNECT_GIVEUP_MS || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
|
||||
reconnectFailed = true;
|
||||
addLog('Switching to slow reconnect mode (every 5 minutes)', 'warn');
|
||||
}
|
||||
|
||||
const delay = reconnectFailed
|
||||
? 300000
|
||||
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
||||
const baseDelay = reconnectFailed
|
||||
? _RECONNECT_SLOW_DELAY
|
||||
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(_RECONNECT_FACTOR, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
||||
// Jitter de-synchronizes herds: many clients dropped by the same server
|
||||
// blip won't all reconnect on the same tick and exhaust the connection
|
||||
// budget in lockstep. Applied in both fast and slow mode.
|
||||
const jitterFactor = 1 - _RECONNECT_JITTER + Math.random() * 2 * _RECONNECT_JITTER;
|
||||
const delay = Math.round(baseDelay * jitterFactor);
|
||||
|
||||
if (reconnectFailed) {
|
||||
addLog(`Slow reconnect in 5min (attempt ${reconnectAttempts})`, 'info');
|
||||
addLog(`Slow reconnect in ~5min (attempt ${reconnectAttempts})`, 'info');
|
||||
} else {
|
||||
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
|
||||
}
|
||||
@@ -665,17 +741,58 @@ function scheduleReconnect() {
|
||||
function emit(event, data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
const msg = `42${JSON.stringify([event, data])}`;
|
||||
socket.send(msg);
|
||||
} else {
|
||||
eventQueue.push({ event, data });
|
||||
if (eventQueue.length > 50) {
|
||||
eventQueue.shift();
|
||||
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
||||
try {
|
||||
socket.send(msg);
|
||||
} catch (e) {
|
||||
// The socket can close between the readyState check and send()
|
||||
// (race with a server-side disconnect). Re-queue so the event is
|
||||
// retried on the next successful (re)connect instead of being lost.
|
||||
addLog(`Send failed, re-queueing ${event}: ${e.message}`, 'warn');
|
||||
queueEvent(event, data);
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue });
|
||||
} else {
|
||||
queueEvent(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
function queueEvent(event, data) {
|
||||
eventQueue.push({ event, data });
|
||||
if (eventQueue.length > 50) {
|
||||
eventQueue.shift();
|
||||
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the offline event queue in paced batches. A reconnect after a long
|
||||
* outage can leave up to 50 queued events; dumping them in one tick would
|
||||
* exceed the server's per-socket event budget and get us disconnected right
|
||||
* after rejoining. We send FLUSH_BATCH_SIZE events, then wait
|
||||
* FLUSH_BATCH_INTERVAL_MS before the next batch. Remaining events drain across
|
||||
* subsequent batches; if the connection drops mid-drain, the rest stay queued.
|
||||
*/
|
||||
function flushEventQueue() {
|
||||
if (flushTimer) return; // a drain is already in progress
|
||||
const drainBatch = () => {
|
||||
flushTimer = null;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
return; // lost the connection — leave the rest queued for next connect
|
||||
}
|
||||
let sent = 0;
|
||||
while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) {
|
||||
const queuedMsg = eventQueue.shift();
|
||||
emit(queuedMsg.event, queuedMsg.data);
|
||||
sent++;
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue }).catch(() => {});
|
||||
if (eventQueue.length > 0) {
|
||||
flushTimer = setTimeout(drainBatch, FLUSH_BATCH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
drainBatch();
|
||||
}
|
||||
|
||||
function addToHistory(action, senderId) {
|
||||
const historyEntry = {
|
||||
action,
|
||||
@@ -692,8 +809,62 @@ function addToHistory(action, senderId) {
|
||||
chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {});
|
||||
}
|
||||
|
||||
// --- Ping / Latency ---
|
||||
function sendPing() {
|
||||
const t = Date.now();
|
||||
pendingPingT = t;
|
||||
emit(EVENTS.PING, { t });
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
pingTimeout = setTimeout(() => {
|
||||
pingTimeout = null;
|
||||
if (pendingPingT !== t) return; // a PONG arrived in time
|
||||
// This ping went unanswered. Tolerate transient blips: only force a
|
||||
// reconnect after MAX_MISSED_PONGS consecutive misses, not the first.
|
||||
pendingPingT = null;
|
||||
missedPongs++;
|
||||
if (missedPongs >= MAX_MISSED_PONGS) {
|
||||
addLog(`${missedPongs} consecutive pings unanswered — force disconnecting to trigger reconnect`, 'warn');
|
||||
missedPongs = 0;
|
||||
forceDisconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
} else {
|
||||
addLog(`Ping unanswered (${missedPongs}/${MAX_MISSED_PONGS}) — retrying next interval`, 'warn');
|
||||
}
|
||||
}, PING_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function startPing() {
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
|
||||
currentPingMs = null;
|
||||
pendingPingT = null;
|
||||
missedPongs = 0;
|
||||
pingInterval = setInterval(sendPing, PING_INTERVAL_MS);
|
||||
sendPing();
|
||||
}
|
||||
|
||||
function stopPing() {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
if (pingTimeout) {
|
||||
clearTimeout(pingTimeout);
|
||||
pingTimeout = null;
|
||||
}
|
||||
currentPingMs = null;
|
||||
pendingPingT = null;
|
||||
missedPongs = 0;
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
function handleServerEvent(event, data) {
|
||||
if (!data) {
|
||||
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
@@ -759,14 +930,19 @@ function handleServerEvent(event, data) {
|
||||
break;
|
||||
case EVENTS.ERROR:
|
||||
isConnecting = false;
|
||||
// If we get a server error before successfully joining a room,
|
||||
// clear connectIntent to prevent an infinite reconnect loop.
|
||||
if (!currentRoom) {
|
||||
connectIntent = false;
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
reconnectAttempts = 0;
|
||||
reconnectFailed = false;
|
||||
}
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog(`Server Error: ${data.message}`, 'error');
|
||||
chrome.storage.sync.get(['locale'], async (settings) => {
|
||||
let lang = settings.locale;
|
||||
if (!lang) {
|
||||
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
|
||||
lang = SUPPORTED_LANGUAGES.includes(systemLang) ? systemLang : 'en';
|
||||
}
|
||||
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
const lang = settings.locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
chrome.notifications.create(`error_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
@@ -876,6 +1052,11 @@ function handleServerEvent(event, data) {
|
||||
|
||||
routeToContent(event, data);
|
||||
break;
|
||||
case EVENTS.PING:
|
||||
if (data && typeof data.t === 'number' && Number.isFinite(data.t) && data.sender) {
|
||||
emit(EVENTS.PONG, { t: data.t, target: data.sender });
|
||||
}
|
||||
break;
|
||||
case EVENTS.EVENT_ACK:
|
||||
if (lastActionState && lastActionState.action && data?.senderId) {
|
||||
// Correlation Check: Only accept ACK if it matches our current action's timestamp
|
||||
@@ -1012,6 +1193,21 @@ function handleServerEvent(event, data) {
|
||||
addLog(`Episode lobby for "${title}" cancelled by ${data.senderId || 'peer'}`, 'warn');
|
||||
}
|
||||
break;
|
||||
case EVENTS.PONG:
|
||||
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
|
||||
if (pendingPingT === data.t) {
|
||||
pendingPingT = null;
|
||||
missedPongs = 0;
|
||||
if (pingTimeout) {
|
||||
clearTimeout(pingTimeout);
|
||||
pingTimeout = null;
|
||||
}
|
||||
const rtt = Date.now() - data.t;
|
||||
currentPingMs = (rtt >= 0 && rtt < 30000) ? rtt : null;
|
||||
chrome.runtime.sendMessage({ type: 'PING_UPDATE', ping: currentPingMs }).catch(() => {});
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
addLog(`Received unknown event from server: ${event}`, 'warn');
|
||||
break;
|
||||
@@ -1097,14 +1293,10 @@ function cancelEpisodeLobby(reason) {
|
||||
};
|
||||
|
||||
// Chrome notification on failure (per Q2: only notify on failure)
|
||||
chrome.storage.sync.get(['browserNotifications', 'locale'], async (settings) => {
|
||||
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
|
||||
let lang = settings.locale;
|
||||
if (!lang) {
|
||||
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
|
||||
lang = SUPPORTED_LANGUAGES.includes(systemLang) ? systemLang : 'en';
|
||||
}
|
||||
const lang = settings.locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
|
||||
const reasonKey = reasonKeys[reason];
|
||||
@@ -1245,7 +1437,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === 'keepAlive') {
|
||||
chrome.storage.session.get('keepAlive', () => {});
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
if (!reconnectFailed) {
|
||||
if (!reconnectFailed && (currentRoom || connectIntent)) {
|
||||
connect();
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
@@ -1273,9 +1465,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
function leaveOldRoomIfSwitching(newRoomId) {
|
||||
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
}
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
@@ -1297,6 +1487,27 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetAudioProcessingInTab(tabId) {
|
||||
if (!tabId) return;
|
||||
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
|
||||
}
|
||||
|
||||
async function applyAudioSettingsToTab(tabId) {
|
||||
if (!tabId) return;
|
||||
let data = (await chrome.storage.local.get(['audioSettings']));
|
||||
if (!data.audioSettings) {
|
||||
const syncData = await chrome.storage.sync.get(['audioSettings']);
|
||||
if (syncData.audioSettings) {
|
||||
data = syncData;
|
||||
await chrome.storage.local.set({ audioSettings: syncData.audioSettings });
|
||||
}
|
||||
}
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: data.audioSettings
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// --- Extension Message Listeners ---
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
handleAsyncMessage(message, sender, sendResponse);
|
||||
@@ -1304,21 +1515,35 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
});
|
||||
|
||||
async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (!message) return;
|
||||
await ensureState();
|
||||
|
||||
if (message.type === 'CONNECT') {
|
||||
const settings = await getSettings();
|
||||
connectIntent = !!settings.roomId;
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
if (typeof sendResponse === 'function') sendResponse({ status: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.roomId) {
|
||||
leaveOldRoomIfSwitching(settings.roomId);
|
||||
}
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
if (settings.roomId) connect();
|
||||
} else if (settings.roomId) {
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId: settings.roomId,
|
||||
@@ -1331,6 +1556,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'RETRY_CONNECT') {
|
||||
connectIntent = true;
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
@@ -1353,11 +1579,17 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
reconnectSlowMode: reconnectFailed,
|
||||
roomId: currentRoom ? currentRoom.roomId : null,
|
||||
serverUrl: currentServerUrl,
|
||||
version: APP_VERSION,
|
||||
version: chrome.runtime.getManifest().version,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
roomPassword: currentRoom ? currentRoom.password : null
|
||||
roomPassword: currentRoom ? currentRoom.password : null,
|
||||
ping: currentPingMs
|
||||
});
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
resetAudioProcessingInTab(currentTabId);
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
@@ -1387,8 +1619,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
episodeLobby: null,
|
||||
expectedAcksCount: 0
|
||||
});
|
||||
chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
forceDisconnect();
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CLEAR_LOGS') {
|
||||
logs = [];
|
||||
@@ -1403,24 +1637,46 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
|
||||
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
|
||||
chrome.storage.sync.set({
|
||||
if (!roomId) {
|
||||
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
|
||||
chrome.runtime.sendMessage(errMsg).catch(() => {});
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
tabs.forEach(tab => chrome.tabs.sendMessage(tab.id, errMsg).catch(() => {}));
|
||||
});
|
||||
sendResponse({ status: 'invalid_room_id' });
|
||||
return;
|
||||
}
|
||||
connectIntent = true;
|
||||
chrome.storage.local.set({
|
||||
roomId,
|
||||
password,
|
||||
useCustomServer: !!useCustomServer,
|
||||
serverUrl: serverUrl || ''
|
||||
}, async () => {
|
||||
const settings = await getSettings();
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'already_joined' });
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
broadcastConnectionStatus('connecting');
|
||||
leaveOldRoomIfSwitching(roomId);
|
||||
const settings = await getSettings();
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
} else {
|
||||
} else if (roomId) {
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId,
|
||||
password,
|
||||
@@ -1460,15 +1716,19 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
updateLastAction(message.action, 'You', timestamp);
|
||||
lastActionState.targetTime = message.payload?.targetTime !== undefined ? message.payload.targetTime : message.payload?.currentTime;
|
||||
|
||||
const payload = message.payload || {};
|
||||
lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime;
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
message.payload.actionTimestamp = timestamp;
|
||||
message.payload.seq = localSeq;
|
||||
|
||||
payload.actionTimestamp = timestamp;
|
||||
payload.seq = localSeq;
|
||||
message.payload = payload;
|
||||
|
||||
// Local Reactive Update
|
||||
updateLocalPeerState(peerId, {
|
||||
playbackState: message.action === EVENTS.PLAY ? 'playing' : (message.action === EVENTS.PAUSE ? 'paused' : undefined),
|
||||
currentTime: message.payload.currentTime !== undefined ? message.payload.currentTime : (message.payload.targetTime !== undefined ? message.payload.targetTime : undefined)
|
||||
currentTime: payload.currentTime !== undefined ? payload.currentTime : (payload.targetTime !== undefined ? payload.targetTime : undefined)
|
||||
});
|
||||
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
@@ -1551,11 +1811,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CMD_ACK') {
|
||||
const commandSenderId = message.commandSenderId;
|
||||
if (commandSenderId && commandSenderId !== peerId) {
|
||||
emit(EVENTS.EVENT_ACK, {
|
||||
senderId: peerId,
|
||||
// Only ACK if the command sender is still a known peer in our room.
|
||||
// If we've already seen their PEER_STATUS 'left', skip the ACK — it would
|
||||
// only be dropped server-side as an absent-peer ACK anyway.
|
||||
const senderStillPresent = currentRoom && Array.isArray(currentRoom.peers) &&
|
||||
currentRoom.peers.some(p => (typeof p === 'object' ? p.peerId : p) === commandSenderId);
|
||||
if (commandSenderId && commandSenderId !== peerId && senderStillPresent) {
|
||||
emit(EVENTS.EVENT_ACK, {
|
||||
senderId: peerId,
|
||||
targetId: commandSenderId,
|
||||
actionTimestamp: message.actionTimestamp
|
||||
actionTimestamp: message.actionTimestamp
|
||||
});
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
@@ -1576,7 +1841,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
markRoomUseful();
|
||||
getSettings().then(settings => {
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||
emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
|
||||
@@ -1599,6 +1865,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'ok' });
|
||||
});
|
||||
} else if (message.type === 'SET_TARGET_TAB') {
|
||||
const previousTabId = currentTabId;
|
||||
currentTabId = message.tabId;
|
||||
currentTabTitle = message.tabTitle;
|
||||
lastContentHeartbeatAt = null;
|
||||
@@ -1607,14 +1874,21 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt });
|
||||
updateBadgeStatus();
|
||||
|
||||
if (previousTabId && previousTabId !== currentTabId) {
|
||||
resetAudioProcessingInTab(previousTabId);
|
||||
}
|
||||
|
||||
if (currentTabId) {
|
||||
const selectedTabId = currentTabId;
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: currentTabId },
|
||||
target: { tabId: selectedTabId },
|
||||
files: ['content.js']
|
||||
}).catch(err => {
|
||||
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
|
||||
});
|
||||
})
|
||||
.then(() => applyAudioSettingsToTab(selectedTabId))
|
||||
.catch(err => {
|
||||
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
sendResponse({ status: 'ok' });
|
||||
@@ -1638,7 +1912,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
|
||||
// Check setting
|
||||
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
|
||||
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
|
||||
if (epSettings.autoSyncNextEpisode === false) {
|
||||
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
|
||||
sendResponse({ status: 'disabled' });
|
||||
@@ -1724,60 +1998,26 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tab removal listener
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
await ensureState();
|
||||
if (tabId === currentTabId) {
|
||||
const wasInRoom = !!currentRoom;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
roomIdleSince = Date.now();
|
||||
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null, roomIdleSince, lastContentHeartbeatAt });
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
|
||||
if (wasInRoom) {
|
||||
const roomAtClose = currentRoom;
|
||||
getSettings().then(settings => {
|
||||
if (currentRoom !== roomAtClose) return;
|
||||
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
playbackState: 'paused',
|
||||
currentTime: null,
|
||||
mediaTitle: null,
|
||||
username: settings.username,
|
||||
tabTitle: null
|
||||
});
|
||||
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
|
||||
if (me && typeof me === 'object') {
|
||||
me.playbackState = 'paused';
|
||||
me.currentTime = null;
|
||||
me.mediaTitle = null;
|
||||
me.tabTitle = null;
|
||||
me.lastHeartbeat = Date.now();
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
initTabManager({
|
||||
getCurrentTabId: () => currentTabId,
|
||||
setCurrentTabId: (val) => { currentTabId = val; },
|
||||
setCurrentTabTitle: (val) => { currentTabTitle = val; },
|
||||
setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; },
|
||||
setRoomIdleSince: (val) => { roomIdleSince = val; },
|
||||
getCurrentRoom: () => currentRoom,
|
||||
getPeerId: () => peerId,
|
||||
getStorageInitialized: () => storageInitialized,
|
||||
updateBadgeStatus,
|
||||
addLog,
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
ensureState,
|
||||
EVENTS
|
||||
});
|
||||
|
||||
// Re-inject on full page refresh
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
await ensureState();
|
||||
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Initial Connect
|
||||
connect();
|
||||
// Initial Connect — only if user has an active room configuration
|
||||
getSettings().then(settings => {
|
||||
connectIntent = !!settings.roomId;
|
||||
if (connectIntent) connect();
|
||||
}).catch(() => connectIntent = false);
|
||||
|
||||
@@ -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,13 +86,35 @@
|
||||
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
|
||||
chrome.storage.local.get(['autoSyncNextEpisode', 'audioSettings'], (data) => {
|
||||
if (data.autoSyncNextEpisode === undefined || data.audioSettings === undefined) {
|
||||
chrome.storage.sync.get(['autoSyncNextEpisode', 'audioSettings'], (syncData) => {
|
||||
const migrate = {};
|
||||
if (data.autoSyncNextEpisode === undefined && syncData.autoSyncNextEpisode !== undefined) {
|
||||
migrate.autoSyncNextEpisode = syncData.autoSyncNextEpisode;
|
||||
}
|
||||
if (data.audioSettings === undefined && syncData.audioSettings !== undefined) {
|
||||
migrate.audioSettings = syncData.audioSettings;
|
||||
}
|
||||
if (Object.keys(migrate).length) chrome.storage.local.set(migrate);
|
||||
_autoSyncEnabled = syncData.autoSyncNextEpisode !== false;
|
||||
_audioSettings = mergeAudioSettings(syncData.audioSettings);
|
||||
const v = findVideo();
|
||||
if (v && _audioProcessingAllowed) applyAudioSettings(v, _audioSettings);
|
||||
});
|
||||
return;
|
||||
}
|
||||
_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;
|
||||
}
|
||||
});
|
||||
@@ -114,6 +160,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 +313,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.
|
||||
@@ -273,7 +461,7 @@
|
||||
ytButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -289,7 +477,7 @@
|
||||
twitchButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -307,7 +495,7 @@
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -344,12 +532,29 @@
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (message.type === 'SERVER_COMMAND') {
|
||||
const { action, payload } = message;
|
||||
let actionCompleted = false;
|
||||
@@ -366,7 +571,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 +598,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) {
|
||||
@@ -582,6 +791,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 +849,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 +862,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 +909,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) {
|
||||
@@ -671,11 +942,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,6 +994,9 @@
|
||||
function setupListeners() {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
if (currentAudioVideo && currentAudioVideo !== video) {
|
||||
bypassCurrentAudioProcessing();
|
||||
}
|
||||
const existing = video._koalaHandlers;
|
||||
if (existing) {
|
||||
video.removeEventListener('play', existing.play);
|
||||
@@ -736,6 +1016,8 @@
|
||||
if (!lastKnownMediaTitle) {
|
||||
lastKnownMediaTitle = getMediaTitle();
|
||||
}
|
||||
|
||||
if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +1032,7 @@
|
||||
if (!video && lastVideoSrc !== undefined) {
|
||||
reportLog('Video element removed from page', 'warn');
|
||||
lastVideoSrc = undefined;
|
||||
closeAudioContext();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
@@ -91,6 +91,7 @@
|
||||
"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",
|
||||
"LABEL_CONN_STATUS": "Verbindungsstatus",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Aktueller WebSocket-Verbindungsstatus",
|
||||
"CONN_STATUS_DISCONNECTED": "Getrennt",
|
||||
@@ -179,5 +180,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!"
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"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",
|
||||
"LABEL_CONN_STATUS": "Connection Status",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Current WebSocket connection state",
|
||||
"CONN_STATUS_DISCONNECTED": "Disconnected",
|
||||
@@ -179,5 +180,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",
|
||||
@@ -58,16 +58,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 +76,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 +88,10 @@
|
||||
"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",
|
||||
"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 +115,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 +127,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,8 +135,8 @@
|
||||
"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...",
|
||||
@@ -149,17 +150,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 +180,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í!"
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"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",
|
||||
"LABEL_CONN_STATUS": "Statut de la connexion",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Statut actuel de la connexion WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Déconnecté",
|
||||
@@ -179,5 +180,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,212 @@
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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,212 @@
|
||||
{
|
||||
"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": "現在このルームに接続している他のユーザー",
|
||||
"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を開く",
|
||||
"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": "切断されました",
|
||||
"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,212 @@
|
||||
{
|
||||
"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": "현재 이 방에 연결되어 있는 다른 사용자들",
|
||||
"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 열기",
|
||||
"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": "연결 끊김",
|
||||
"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,212 @@
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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,212 @@
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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,99 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -145,39 +146,67 @@
|
||||
"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,212 @@
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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!"
|
||||
}
|
||||
@@ -91,6 +91,7 @@
|
||||
"BTN_REGEN_ID": "Сбросить Peer ID",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Сбросить ваш внутренний идентификатор и переподключиться",
|
||||
"REGEN_ID_DESC": "Используйте при появлении ошибок дублирования идентификации.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Другая проблема? Откройте Issue на GitHub",
|
||||
"LABEL_CONN_STATUS": "Статус подключения",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Текущий статус WebSocket-соединения с сервером",
|
||||
"CONN_STATUS_DISCONNECTED": "Отключено",
|
||||
@@ -179,5 +180,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,212 @@
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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,212 @@
|
||||
{
|
||||
"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": "Інші користувачі зараз підключені до цієї кімнати",
|
||||
"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",
|
||||
"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": "Відключено",
|
||||
"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,212 @@
|
||||
{
|
||||
"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": "当前连接到该房间的其他用户",
|
||||
"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 问题",
|
||||
"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": "已断开连接",
|
||||
"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.5",
|
||||
"version": "2.4.3",
|
||||
"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(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,13 @@
|
||||
<meta charset="utf-8">
|
||||
<title>KoalaSync</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
src: url('assets/TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
@@ -23,7 +30,7 @@
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -127,7 +134,7 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input, select {
|
||||
input, select, option {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--card);
|
||||
@@ -139,6 +146,20 @@
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
#langSelector,
|
||||
#langSelector::picker(select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
#langSelector::picker(select) {
|
||||
background-color: var(--bg);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
font-family: 'Twemoji Country Flags', inherit;
|
||||
}
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
@@ -310,6 +331,47 @@
|
||||
transform: translateX(16px);
|
||||
background-color: white;
|
||||
}
|
||||
.popup-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
padding-top: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.popup-footer a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s, opacity 0.2s;
|
||||
}
|
||||
.popup-footer a:hover {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.feature-hint {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
margin-left: 4px;
|
||||
animation: featurePulse 2s ease-in-out infinite;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes featurePulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.3); }
|
||||
}
|
||||
@keyframes spotlightPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 0px var(--accent); }
|
||||
50% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 6px var(--accent); }
|
||||
}
|
||||
@keyframes floatHint {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -375,9 +437,9 @@
|
||||
<div class="info-card" style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; border-left: 4px solid var(--accent);">
|
||||
<div>
|
||||
<label style="margin-bottom: 0;" title="The room you are currently connected to" data-i18n="LABEL_ACTIVE_ROOM" data-i18n-title="LABEL_ACTIVE_ROOM_TOOLTIP">Active Room</label>
|
||||
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;" data-i18n="ACTIVE_ROOM_NONE">NONE</div>
|
||||
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;">NONE</div>
|
||||
</div>
|
||||
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" data-i18n="ACTIVE_SERVER_OFFICIAL">Official Server</div>
|
||||
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Official Server</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card" style="margin-bottom: 20px;">
|
||||
@@ -403,11 +465,16 @@
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<!-- SYNC ACTIVE: Visible when in a room -->
|
||||
<div id="sync-active">
|
||||
<div class="form-group">
|
||||
<div class="form-group" style="position: relative;">
|
||||
<label title="Choose the browser tab containing the video to sync" data-i18n="LABEL_SELECT_VIDEO" data-i18n-title="LABEL_SELECT_VIDEO_TOOLTIP">Select Video</label>
|
||||
<select id="targetTab">
|
||||
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
|
||||
</select>
|
||||
<!-- Hint Tooltip -->
|
||||
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: white; padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
|
||||
Select your video here!
|
||||
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
@@ -495,22 +562,49 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<label id="audioProcessingLabel" style="margin-bottom: 0; cursor: help; white-space: nowrap; font-size: 13px;" data-i18n="LABEL_AUDIO_PROCESSING" data-i18n-title="LABEL_AUDIO_PROCESSING_TOOLTIP">Audio Processing</label>
|
||||
</div>
|
||||
<a id="audioSettingsLink" href="#" style="font-size: 11px; color: var(--accent); text-decoration: none; white-space: nowrap; background: rgba(99,102,241,0.12); padding: 5px 12px; border-radius: 6px; font-weight: 600; transition: background 0.2s;" data-i18n="AUDIO_OPEN_SETTINGS">Open</a>
|
||||
</div>
|
||||
|
||||
<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" data-i18n-title="LABEL_LANGUAGE_TOOLTIP" title="Choose your preferred extension language">App Language</label>
|
||||
<select id="langSelector" style="width: 150px; background: var(--bg); border: 1px solid #334155; color: white; padding: 6px 10px; border-radius: 8px; font-size: 13px; cursor: pointer; outline: none; font-family: inherit;">
|
||||
<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 id="langSelector" style="width: 165px; background: var(--bg); border: 1px solid #334155; color: white; padding: 6px 10px; border-radius: 8px; font-size: 13px; cursor: pointer; outline: none; font-family: inherit;">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="zh">🇨🇳 中文</option>
|
||||
<option value="uk">🇺🇦 Українська</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
|
||||
<label title="Tools for fixing connection issues" data-i18n="LABEL_TROUBLESHOOTING" data-i18n-title="LABEL_TROUBLESHOOTING_TOOLTIP">Troubleshooting</label>
|
||||
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
|
||||
<button id="restartTourBtn" class="secondary" style="width: 100%; font-size: 11px;" title="Restart the onboarding tutorial" data-i18n="BTN_RESTART_TOUR" data-i18n-title="BTN_RESTART_TOUR_TOOLTIP">Restart Tutorial</button>
|
||||
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px; margin-top: 8px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
|
||||
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;" data-i18n="REGEN_ID_DESC">Use this if you see "Duplicate Identity" errors.</p>
|
||||
<p style="font-size: 9px; text-align: center; margin-top: 6px;"><a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" style="color: var(--accent); text-decoration: none;" data-i18n="REGEN_ID_OTHER_ISSUE">Other issue? Open a GitHub Issue</a></p>
|
||||
</div>
|
||||
|
||||
<div class="popup-footer">
|
||||
<div style="font-size: 9px; color: var(--text-muted); opacity: 0.6; margin-bottom: 6px; line-height: 1.3;" data-i18n="FOOTER_SUPPORT_PROMPT">Enjoying KoalaSync? Leave a review!</div>
|
||||
<a id="settingsReviewLink" href="#" target="_blank" data-i18n="FOOTER_REVIEW">★ Rate us</a>
|
||||
<span> · </span>
|
||||
<a id="settingsSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">Support KoalaSync</a>
|
||||
<span> · </span>
|
||||
<span id="settingsVersion">v0.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -519,13 +613,14 @@
|
||||
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
|
||||
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
|
||||
<span id="connDot" class="status-dot status-offline"></span>
|
||||
<span id="connText" style="flex:1;" data-i18n="CONN_STATUS_DISCONNECTED">Disconnected</span>
|
||||
<span id="connText" style="flex:1;">Disconnected</span>
|
||||
<span id="connPing" style="font-size:11px; font-family:monospace; font-weight:600; opacity:0.8;"></span>
|
||||
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px; margin: 0;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
|
||||
</div>
|
||||
|
||||
<label title="Technical details about the currently selected video element" data-i18n="LABEL_VIDEO_DEBUG" data-i18n-title="LABEL_VIDEO_DEBUG_TOOLTIP">Video Debug Info</label>
|
||||
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;" data-i18n="VIDEO_DEBUG_EMPTY">
|
||||
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;">
|
||||
No tab selected or video detected.
|
||||
</div>
|
||||
|
||||
@@ -542,25 +637,47 @@
|
||||
</div>
|
||||
<div id="logList"></div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px;">
|
||||
<div class="popup-footer">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;" data-i18n="LABEL_GITHUB">GitHub Repository</a>
|
||||
<div id="appVersion" style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v0.0.0</div>
|
||||
<div style="margin-top: 4px;">
|
||||
<div style="font-size: 9px; color: var(--text-muted); opacity: 0.6; margin-bottom: 6px; line-height: 1.3;" data-i18n="FOOTER_SUPPORT_PROMPT">Enjoying KoalaSync? Leave a review!</div>
|
||||
<a id="devReviewLink" href="#" target="_blank" data-i18n="FOOTER_REVIEW">★ Rate us</a>
|
||||
<span> · </span>
|
||||
<a id="devSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">Support KoalaSync</a>
|
||||
<span> · </span>
|
||||
<span id="appVersion">v0.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js" type="module"></script>
|
||||
|
||||
<!-- Onboarding Overlay -->
|
||||
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter: blur(2px); z-index:1000; align-items:flex-end; justify-content:center; padding-bottom: 20px;">
|
||||
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; width:90%; text-align:center; box-shadow:0 8px 32px rgba(0,0,0,0.5); ">
|
||||
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">👋</div>
|
||||
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 8px; font-size:16px;">Welcome to KoalaSync!</h2>
|
||||
<p id="onboarding-text" style="color:var(--text-muted); font-size:13px; margin:0 0 16px; line-height:1.4;">Let's get you started.</p>
|
||||
<div style="display:flex; gap:8px; justify-content:center;">
|
||||
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
|
||||
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
|
||||
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:transparent; z-index:1000; pointer-events:auto;">
|
||||
<!-- Spotlight element -->
|
||||
<div id="onboarding-spotlight" style="position:fixed; pointer-events:none; border:2px solid var(--accent); box-shadow:0 0 0 9999px rgba(15, 23, 42, 0.65); border-radius:8px; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display:none; opacity:0; z-index:1001; animation: spotlightPulse 2s infinite ease-in-out;"></div>
|
||||
|
||||
<!-- Onboarding Card -->
|
||||
<div id="onboarding-card" style="position:fixed; background:var(--card); padding:16px 20px; border-radius:12px; width:280px; box-shadow:0 8px 32px rgba(0,0,0,0.5); z-index:1002; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-sizing:border-box; border:1px solid #334155; opacity:0; transform: scale(0.9); display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
|
||||
<span id="onboarding-icon" style="font-size:24px;">👋</span>
|
||||
<span id="onboarding-step-indicator" style="font-size:10px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:0.5px;">Step 1 of 5</span>
|
||||
</div>
|
||||
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
|
||||
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 6px; font-size:14px; font-weight:700; text-align:left;">Welcome to KoalaSync!</h2>
|
||||
<p id="onboarding-text" style="color:var(--text-muted); font-size:12px; margin:0 0 16px; line-height:1.4; text-align:left;">Let's get you started.</p>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div id="onboarding-progress-container" style="height:4px; background:#334155; border-radius:2px; margin-bottom:16px; overflow:hidden;">
|
||||
<div id="onboarding-progress-bar" style="height:100%; width:20%; background:var(--accent); transition:width 0.3s ease;"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; justify-content:space-between; align-items:center;">
|
||||
<button id="onboarding-skip" class="secondary" style="width:auto; padding:6px 12px; margin:0; font-size:11px;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
|
||||
<button id="onboarding-next" class="primary" style="width:auto; padding:6px 16px; margin:0; font-size:11px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip pointer arrow -->
|
||||
<div id="onboarding-arrow" style="position:absolute; width:0; height:0; border:6px solid transparent; transition:all 0.3s ease;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EVENTS, OFFICIAL_LANDING_PAGE_URL } from './shared/constants.js';
|
||||
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
|
||||
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||||
import { loadLocale, translateDOM, getMessage } from './i18n.js';
|
||||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||||
|
||||
|
||||
const elements = {
|
||||
@@ -16,6 +16,7 @@ const elements = {
|
||||
clearLogs: document.getElementById('clearLogs'),
|
||||
connDot: document.getElementById('connDot'),
|
||||
connText: document.getElementById('connText'),
|
||||
connPing: document.getElementById('connPing'),
|
||||
serverUrl: document.getElementById('serverUrl'),
|
||||
serverOfficial: document.getElementById('serverOfficial'),
|
||||
serverCustom: document.getElementById('serverCustom'),
|
||||
@@ -28,6 +29,7 @@ const elements = {
|
||||
inviteLink: document.getElementById('inviteLink'),
|
||||
filterNoise: document.getElementById('filterNoise'),
|
||||
regenId: document.getElementById('regenId'),
|
||||
restartTourBtn: document.getElementById('restartTourBtn'),
|
||||
lastActionCard: document.getElementById('lastActionCard'),
|
||||
historyList: document.getElementById('historyList'),
|
||||
copyLogs: document.getElementById('copyLogs'),
|
||||
@@ -50,9 +52,16 @@ const elements = {
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||||
browserNotifications: document.getElementById('browserNotifications'),
|
||||
autoCopyInvite: document.getElementById('autoCopyInvite'),
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite'),
|
||||
cancelLobbyBtn: document.getElementById('cancelLobbyBtn'),
|
||||
langSelector: document.getElementById('langSelector')
|
||||
langSelector: document.getElementById('langSelector'),
|
||||
|
||||
audioSettingsLink: document.getElementById('audioSettingsLink'),
|
||||
settingsSupportLink: document.getElementById('settingsSupportLink'),
|
||||
settingsReviewLink: document.getElementById('settingsReviewLink'),
|
||||
settingsVersion: document.getElementById('settingsVersion'),
|
||||
devSupportLink: document.getElementById('devSupportLink'),
|
||||
devReviewLink: document.getElementById('devReviewLink'),
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
@@ -60,6 +69,7 @@ let lastPeersJson = null;
|
||||
let lastKnownPeers = [];
|
||||
let isDevTabVisible = false;
|
||||
let reconnectSlowMode = false;
|
||||
let isProcessingConnection = false;
|
||||
let joinBtnTimeout = null;
|
||||
let forceSyncResetTimer = null;
|
||||
let popupIntervals = [];
|
||||
@@ -71,8 +81,65 @@ let pendingConnectionErrorMsg = null;
|
||||
let roomListRefreshTimer = null;
|
||||
let roomListRefreshInterval = null;
|
||||
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
|
||||
const FEATURE_HINTS = [
|
||||
{
|
||||
key: 'audio_processing',
|
||||
selector: '#audioProcessingLabel',
|
||||
position: 'beforebegin',
|
||||
i18nTooltip: 'NEW_FEATURE_AUDIO'
|
||||
}
|
||||
];
|
||||
|
||||
// --- Helpers ---
|
||||
function configureFooterLinks() {
|
||||
const reviewUrl = getReviewUrl();
|
||||
[elements.settingsSupportLink, elements.devSupportLink].forEach(link => {
|
||||
if (link) link.href = SUPPORT_URL;
|
||||
});
|
||||
[elements.settingsReviewLink, elements.devReviewLink].forEach(link => {
|
||||
if (link) link.href = reviewUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function openAudioSettingsPage() {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('audio-options.html') });
|
||||
}
|
||||
|
||||
async function updateFeatureHints() {
|
||||
const data = await chrome.storage.sync.get(['dismissedHints']);
|
||||
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
|
||||
|
||||
FEATURE_HINTS.forEach(feature => {
|
||||
const target = document.querySelector(feature.selector);
|
||||
if (!target) return;
|
||||
|
||||
const existing = target.parentElement?.querySelector(`.feature-hint[data-feature="${feature.key}"]`);
|
||||
if (dismissed.includes(feature.key)) {
|
||||
if (existing) existing.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'feature-hint';
|
||||
hint.dataset.feature = feature.key;
|
||||
hint.title = getMessage(feature.i18nTooltip);
|
||||
target.insertAdjacentElement(feature.position || 'afterend', hint);
|
||||
} else {
|
||||
existing.title = getMessage(feature.i18nTooltip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function dismissFeatureHint(featureKey) {
|
||||
const data = await chrome.storage.sync.get(['dismissedHints']);
|
||||
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
|
||||
if (!dismissed.includes(featureKey)) {
|
||||
await chrome.storage.sync.set({ dismissedHints: [...dismissed, featureKey] });
|
||||
}
|
||||
await updateFeatureHints();
|
||||
}
|
||||
|
||||
function clearConnectionErrorTimer() {
|
||||
if (connectionErrorTimer) {
|
||||
clearTimeout(connectionErrorTimer);
|
||||
@@ -108,14 +175,24 @@ function setRoomRefreshCooldown() {
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale']);
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
|
||||
// Migrate preferences from sync → local for existing users
|
||||
const oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
|
||||
const toMigrate = {};
|
||||
for (const key of ['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']) {
|
||||
if (localData[key] === undefined && oldSync[key] !== undefined) {
|
||||
toMigrate[key] = oldSync[key];
|
||||
localData[key] = oldSync[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(toMigrate).length) {
|
||||
await chrome.storage.local.set(toMigrate);
|
||||
}
|
||||
|
||||
let activeLang = data.locale;
|
||||
let activeLang = localData.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 });
|
||||
activeLang = getSystemLanguage();
|
||||
chrome.storage.local.set({ locale: activeLang });
|
||||
}
|
||||
|
||||
await loadLocale(activeLang);
|
||||
@@ -123,21 +200,21 @@ async function init() {
|
||||
|
||||
if (elements.langSelector) elements.langSelector.value = activeLang;
|
||||
|
||||
let username = data.username;
|
||||
let username = localData.username;
|
||||
if (!username) {
|
||||
username = generateUsername();
|
||||
chrome.storage.sync.set({ username });
|
||||
await chrome.storage.local.set({ username });
|
||||
}
|
||||
|
||||
elements.serverUrl.value = data.serverUrl || '';
|
||||
elements.roomId.value = data.roomId || '';
|
||||
elements.password.value = data.password || '';
|
||||
elements.serverUrl.value = localData.serverUrl || '';
|
||||
elements.roomId.value = localData.roomId || '';
|
||||
elements.password.value = localData.password || '';
|
||||
elements.username.value = username;
|
||||
if (elements.filterNoise) elements.filterNoise.checked = data.filterNoise !== false;
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
|
||||
if (elements.forceSyncMode) elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
|
||||
if (elements.browserNotifications) elements.browserNotifications.checked = data.browserNotifications === true;
|
||||
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = data.autoCopyInvite !== false;
|
||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||
if (elements.forceSyncMode) elements.forceSyncMode.value = localData.forceSyncMode || 'jump-to-others';
|
||||
if (elements.browserNotifications) elements.browserNotifications.checked = localData.browserNotifications === true;
|
||||
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = localData.autoCopyInvite !== false;
|
||||
|
||||
// Set Version Info
|
||||
const versionTxt = `v${chrome.runtime.getManifest().version}`;
|
||||
@@ -145,18 +222,23 @@ async function init() {
|
||||
if (versionEl) versionEl.textContent = versionTxt;
|
||||
const popupVerEl = document.getElementById('popupVersion');
|
||||
if (popupVerEl) popupVerEl.textContent = versionTxt;
|
||||
if (elements.settingsVersion) elements.settingsVersion.textContent = versionTxt;
|
||||
configureFooterLinks();
|
||||
await updateFeatureHints();
|
||||
|
||||
if (data.useCustomServer) {
|
||||
if (localData.useCustomServer) {
|
||||
setServerMode(true);
|
||||
} else {
|
||||
setServerMode(false);
|
||||
}
|
||||
|
||||
toggleUIState(!!data.roomId);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
toggleUIState(!!localData.roomId);
|
||||
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl);
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
|
||||
// Initial Status Check (status shows via GET_STATUS below)
|
||||
|
||||
// Initial Status Check
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
@@ -168,17 +250,38 @@ async function init() {
|
||||
localPeerId = res.peerId;
|
||||
reconnectSlowMode = res.reconnectSlowMode || false;
|
||||
applyConnectionStatus(res.status);
|
||||
updatePingDisplay(res.ping);
|
||||
updatePeerList(res.peers);
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
// If user has a room configured but background is not connected,
|
||||
// trigger connection now — the popup opening is explicit user intent.
|
||||
if (res.status === 'disconnected' && localData.roomId) {
|
||||
chrome.runtime.sendMessage({ type: 'CONNECT' }).catch(() => {});
|
||||
applyConnectionStatus('connecting');
|
||||
}
|
||||
|
||||
// Populate Tabs using the background's targetTabId
|
||||
await populateTabs(res.peers, res.targetTabId);
|
||||
|
||||
// Render lobby status if active
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
|
||||
if (res.status === 'connected' && !res.targetTabId && localData.roomId) {
|
||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||
if (syncTabBtn) syncTabBtn.click();
|
||||
showSelectVideoHint();
|
||||
} else if (localData.activeTab) {
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
} else {
|
||||
await populateTabs();
|
||||
if (localData.activeTab) {
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -400,6 +503,24 @@ function renderEmpty(container, type) {
|
||||
wrapper.appendChild(iconDiv);
|
||||
wrapper.appendChild(titleDiv);
|
||||
wrapper.appendChild(hintDiv);
|
||||
|
||||
// "No peers yet" — turn the hint into a one-click action so the user can
|
||||
// immediately copy and share the invite link instead of hunting for it.
|
||||
if (type === 'peers' && elements.inviteLink && elements.inviteLink.value) {
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'primary';
|
||||
copyBtn.style.cssText = 'display:inline-block; width:auto; margin-top:12px; padding:6px 14px; font-size:11px;';
|
||||
copyBtn.textContent = getMessage('BTN_COPY_INVITE');
|
||||
copyBtn.title = getMessage('BTN_COPY_INVITE_TOOLTIP');
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value)
|
||||
.then(() => showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000))
|
||||
.catch(() => showToast(getMessage('TOAST_COPY_FAILED'), 'error'));
|
||||
});
|
||||
wrapper.appendChild(copyBtn);
|
||||
}
|
||||
|
||||
container.replaceChildren(wrapper);
|
||||
}
|
||||
|
||||
@@ -571,7 +692,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
const token = {};
|
||||
populateTabsToken = token;
|
||||
|
||||
const data = await chrome.storage.sync.get(['filterNoise']);
|
||||
const data = await chrome.storage.local.get(['filterNoise']);
|
||||
const isFilterActive = data.filterNoise !== false;
|
||||
|
||||
let currentTargetTabId = providedTargetTabId;
|
||||
@@ -732,6 +853,9 @@ function applyConnectionStatus(status) {
|
||||
if (elements.connText) {
|
||||
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : getMessage('STATUS_DISCONNECTED')));
|
||||
}
|
||||
if (!connected) {
|
||||
updatePingDisplay(null);
|
||||
}
|
||||
if (elements.retryBtn) {
|
||||
elements.retryBtn.style.display = reconnecting && reconnectSlowMode ? 'block' : 'none';
|
||||
}
|
||||
@@ -751,6 +875,23 @@ function applyConnectionStatus(status) {
|
||||
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = getMessage('BTN_SYNC');
|
||||
}
|
||||
|
||||
function updatePingDisplay(pingMs) {
|
||||
if (!elements.connPing) return;
|
||||
if (pingMs === null || pingMs === undefined || typeof pingMs !== 'number' || !Number.isFinite(pingMs)) {
|
||||
elements.connPing.textContent = '';
|
||||
elements.connPing.style.color = '';
|
||||
return;
|
||||
}
|
||||
elements.connPing.textContent = `${Math.round(pingMs)}ms`;
|
||||
if (pingMs < 50) {
|
||||
elements.connPing.style.color = '#22c55e';
|
||||
} else if (pingMs < 150) {
|
||||
elements.connPing.style.color = '#f59e0b';
|
||||
} else {
|
||||
elements.connPing.style.color = '#ef4444';
|
||||
}
|
||||
}
|
||||
|
||||
function updateHistory(history) {
|
||||
if (!history || !elements.historyList) return;
|
||||
elements.historyList.innerHTML = '';
|
||||
@@ -879,7 +1020,7 @@ function checkInviteLink() {
|
||||
if (serverUrl || useCustomServer) {
|
||||
elements.serverUrl.value = serverUrl;
|
||||
setServerMode(useCustomServer);
|
||||
chrome.storage.sync.set({ serverUrl, useCustomServer });
|
||||
chrome.storage.local.set({ serverUrl, useCustomServer });
|
||||
}
|
||||
|
||||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||||
@@ -896,9 +1037,9 @@ function setServerMode(custom) {
|
||||
elements.serverOfficial.classList.toggle('active', !custom);
|
||||
elements.serverCustom.classList.toggle('active', custom);
|
||||
elements.serverUrl.style.display = custom ? 'block' : 'none';
|
||||
chrome.storage.sync.get(['useCustomServer', 'serverUrl'], (data) => {
|
||||
chrome.storage.local.get(['useCustomServer', 'serverUrl'], (data) => {
|
||||
if (data.useCustomServer !== custom) {
|
||||
chrome.storage.sync.set({ useCustomServer: custom });
|
||||
chrome.storage.local.set({ useCustomServer: custom });
|
||||
if (!custom || data.serverUrl) {
|
||||
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
||||
}
|
||||
@@ -910,46 +1051,84 @@ elements.serverOfficial.addEventListener('click', () => setServerMode(false));
|
||||
elements.serverCustom.addEventListener('click', () => setServerMode(true));
|
||||
|
||||
elements.filterNoise.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ filterNoise: elements.filterNoise.checked }, () => {
|
||||
chrome.storage.local.set({ filterNoise: elements.filterNoise.checked }, () => {
|
||||
populateTabs();
|
||||
});
|
||||
});
|
||||
|
||||
elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
elements.browserNotifications.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ browserNotifications: elements.browserNotifications.checked });
|
||||
chrome.storage.local.set({ browserNotifications: elements.browserNotifications.checked });
|
||||
});
|
||||
|
||||
if (elements.autoCopyInvite) {
|
||||
elements.autoCopyInvite.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ autoCopyInvite: elements.autoCopyInvite.checked });
|
||||
chrome.storage.local.set({ autoCopyInvite: elements.autoCopyInvite.checked });
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.audioSettingsLink) {
|
||||
elements.audioSettingsLink.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
await dismissFeatureHint('audio_processing');
|
||||
openAudioSettingsPage();
|
||||
});
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'sync' || !changes.audioSettings) return;
|
||||
});
|
||||
|
||||
elements.forceSyncMode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value });
|
||||
chrome.storage.local.set({ forceSyncMode: elements.forceSyncMode.value });
|
||||
});
|
||||
|
||||
elements.serverUrl.addEventListener('input', () => {
|
||||
chrome.storage.sync.set({ serverUrl: elements.serverUrl.value });
|
||||
chrome.storage.local.set({ serverUrl: elements.serverUrl.value });
|
||||
});
|
||||
|
||||
elements.username.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ username: elements.username.value });
|
||||
chrome.storage.local.set({ username: elements.username.value });
|
||||
});
|
||||
|
||||
if (elements.langSelector) {
|
||||
elements.langSelector.addEventListener('change', async () => {
|
||||
const selectedLang = elements.langSelector.value;
|
||||
await chrome.storage.sync.set({ locale: selectedLang });
|
||||
await chrome.storage.local.set({ locale: selectedLang });
|
||||
await loadLocale(selectedLang);
|
||||
translateDOM();
|
||||
configureFooterLinks();
|
||||
await updateFeatureHints();
|
||||
|
||||
// Re-apply connection and room UI state since translateDOM may overwrite dynamic elements
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (res) {
|
||||
localPeerId = res.peerId;
|
||||
reconnectSlowMode = res.reconnectSlowMode || false;
|
||||
applyConnectionStatus(res.status);
|
||||
updatePeerList(res.peers);
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
|
||||
await populateTabs(res.peers, res.targetTabId);
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
} else {
|
||||
applyConnectionStatus('disconnected');
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
await populateTabs();
|
||||
}
|
||||
});
|
||||
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
populateTabs();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -958,7 +1137,7 @@ elements.serverUrl.addEventListener('change', () => {
|
||||
if (url && !url.includes('://')) {
|
||||
url = 'ws://' + url;
|
||||
elements.serverUrl.value = url;
|
||||
chrome.storage.sync.set({ serverUrl: url });
|
||||
chrome.storage.local.set({ serverUrl: url });
|
||||
}
|
||||
if (elements.serverCustom.classList.contains('active') && url) {
|
||||
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
||||
@@ -981,6 +1160,8 @@ elements.tabs.forEach(btn => {
|
||||
isDevTabVisible = btn.dataset.tab === 'tab-dev';
|
||||
if (isDevTabVisible) refreshLogs();
|
||||
if (btn.dataset.tab === 'tab-sync') refreshHistory();
|
||||
|
||||
chrome.storage.local.set({ activeTab: btn.dataset.tab });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1023,8 +1204,13 @@ elements.roomId.addEventListener('input', () => {
|
||||
});
|
||||
|
||||
elements.joinBtn.addEventListener('click', async () => {
|
||||
if (isProcessingConnection) return;
|
||||
isProcessingConnection = true;
|
||||
clearConnectionErrorTimer();
|
||||
if (elements.joinBtn.disabled) return;
|
||||
if (elements.joinBtn.disabled) {
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
const roomIdInput = elements.roomId.value.trim();
|
||||
const isCreating = !roomIdInput;
|
||||
|
||||
@@ -1033,10 +1219,23 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
|
||||
if (joinBtnTimeout) clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = setTimeout(() => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.status === 'connecting') {
|
||||
joinBtnTimeout = setTimeout(() => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
}, 15000);
|
||||
return;
|
||||
}
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
if (res && res.status !== 'connected') showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const serverUrl = elements.serverUrl.value.trim();
|
||||
@@ -1047,6 +1246,8 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
if (useCustom && serverUrl) {
|
||||
@@ -1057,6 +1258,8 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1072,7 +1275,7 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
window.justCreatedRoom = true;
|
||||
}
|
||||
|
||||
await chrome.storage.sync.set({ serverUrl, roomId, password, useCustomServer: useCustom });
|
||||
await chrome.storage.local.set({ serverUrl, roomId, password, useCustomServer: useCustom });
|
||||
elements.roomId.value = roomId;
|
||||
|
||||
// Tell background to connect
|
||||
@@ -1083,13 +1286,16 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
elements.leaveBtn.addEventListener('click', async () => {
|
||||
if (isProcessingConnection) return;
|
||||
isProcessingConnection = true;
|
||||
clearConnectionErrorTimer();
|
||||
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
||||
await chrome.storage.sync.set({ roomId: '', password: '' });
|
||||
await chrome.storage.local.set({ roomId: '', password: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
lastKnownPeers = [];
|
||||
updateUI(null, null);
|
||||
isProcessingConnection = false;
|
||||
});
|
||||
|
||||
function generateRoomId() {
|
||||
@@ -1140,6 +1346,9 @@ elements.retryBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.targetTab.addEventListener('change', () => {
|
||||
const hint = document.getElementById('targetTabHint');
|
||||
if (hint) hint.style.display = 'none';
|
||||
|
||||
const val = elements.targetTab.value;
|
||||
const tabId = val ? parseInt(val) : null;
|
||||
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
|
||||
@@ -1149,8 +1358,14 @@ elements.targetTab.addEventListener('change', () => {
|
||||
elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (elements.forceSyncBtn.disabled) return;
|
||||
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
elements.forceSyncBtn.disabled = true;
|
||||
|
||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||
if (chrome.runtime.lastError || !status || !status.targetTabId) return;
|
||||
if (chrome.runtime.lastError || !status || !status.targetTabId) {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = elements.forceSyncMode.value;
|
||||
let targetTime = null;
|
||||
@@ -1158,6 +1373,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (mode === 'jump-to-others') {
|
||||
if (!localPeerId) {
|
||||
showError(getMessage('ERR_IDENTITY_NOT_LOADED'));
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const peers = status.peers || [];
|
||||
@@ -1167,6 +1383,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
|
||||
if (otherTimes.length === 0) {
|
||||
showError(getMessage('ERR_NO_PEERS_TIME'));
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1175,8 +1392,6 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
targetTime = otherTimes.length % 2 !== 0 ? otherTimes[mid] : (otherTimes[mid - 1] + otherTimes[mid]) / 2;
|
||||
}
|
||||
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
elements.forceSyncBtn.disabled = true;
|
||||
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? getMessage('BTN_STATE_SYNCING_GROUP', { time: formatTime(targetTime) }) : getMessage('BTN_STATE_SYNCING');
|
||||
forceSyncDone = false;
|
||||
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
|
||||
@@ -1354,6 +1569,7 @@ async function refreshLogs() {
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (!msg) return;
|
||||
if (msg.type === 'LOG_UPDATE') {
|
||||
refreshLogs();
|
||||
if (msg.log && msg.log.type === 'error') {
|
||||
@@ -1431,6 +1647,9 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
updatePeerList(msg.peers);
|
||||
if (msg.peers) detectPeerChanges(msg.peers);
|
||||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||||
if (msg.status === 'connected' || msg.status === 'disconnected') {
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
}
|
||||
if (msg.status === 'connected') {
|
||||
clearConnectionErrorTimer();
|
||||
}
|
||||
@@ -1440,8 +1659,10 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
}
|
||||
if (msg.status === 'connected') {
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.peers) updatePeerList(res.peers);
|
||||
if (res && res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
if (!res) return;
|
||||
if (res.peers) updatePeerList(res.peers);
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
updatePingDisplay(res.ping);
|
||||
});
|
||||
}
|
||||
if (msg.status === 'disconnected') {
|
||||
@@ -1460,11 +1681,14 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'PING_UPDATE') {
|
||||
updatePingDisplay(msg.ping);
|
||||
} else if (msg.type === 'HISTORY_UPDATE') {
|
||||
updateHistory(msg.history);
|
||||
} else if (msg.type === 'ROOM_LIST') {
|
||||
updateRoomList(msg.rooms);
|
||||
} else if (msg.type === 'JOIN_STATUS') {
|
||||
isProcessingConnection = false;
|
||||
if (joinBtnTimeout) {
|
||||
clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = null;
|
||||
@@ -1474,11 +1698,17 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
|
||||
if (msg.success) {
|
||||
// Final confirmation of join from background
|
||||
chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||
if (syncTabBtn) syncTabBtn.click();
|
||||
showSelectVideoHint();
|
||||
});
|
||||
} else {
|
||||
// Join failed: reset UI state
|
||||
chrome.storage.local.set({ roomId: '', password: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
updateUI(null, null);
|
||||
}
|
||||
} else if (msg.type === 'LOBBY_UPDATE') {
|
||||
@@ -1566,6 +1796,7 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
: '\uD83D\uDD34';
|
||||
lines.push(`- **Status:** ${emoji} ${st}`);
|
||||
lines.push(`- **Server:** \`${safe(status.serverUrl, '?')}\``);
|
||||
lines.push(`- **Ping:** ${status.ping != null ? `${status.ping}ms` : '-'}`);
|
||||
if (status.roomId) {
|
||||
lines.push(`- **Room:** \`${status.roomId}\``);
|
||||
const peers = Array.isArray(status.peers) ? status.peers : [];
|
||||
@@ -1629,7 +1860,7 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
// ── Action History (last 20) ──
|
||||
lines.push('## Action History (last 20)');
|
||||
if (history && history.length > 0) {
|
||||
const recent = history.slice(-20);
|
||||
const recent = history.slice(0, 20).reverse();
|
||||
lines.push('```');
|
||||
for (const h of recent) {
|
||||
if (!h) continue;
|
||||
@@ -1648,7 +1879,7 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
// ── Logs (last 50) ──
|
||||
lines.push('## Logs (last 50)');
|
||||
if (logs && logs.length > 0) {
|
||||
const recent = logs.slice(-50);
|
||||
const recent = logs.slice(0, 50).reverse();
|
||||
lines.push('```');
|
||||
for (const l of recent) {
|
||||
if (!l) continue;
|
||||
@@ -1903,36 +2134,188 @@ function updateLobbyUI(lobby, peers) {
|
||||
|
||||
// --- Onboarding Tour ---
|
||||
const onboardingSteps = [
|
||||
{ icon: '👋', get title() { return getMessage('ONBOARDING_1_TITLE'); }, get text() { return getMessage('ONBOARDING_1_TEXT'); }, targetTab: 'tab-room' },
|
||||
{ icon: '🏠', get title() { return getMessage('ONBOARDING_2_TITLE'); }, get text() { return getMessage('ONBOARDING_2_TEXT'); }, targetTab: 'tab-room' },
|
||||
{ icon: '🎬', get title() { return getMessage('ONBOARDING_3_TITLE'); }, get text() { return getMessage('ONBOARDING_3_TEXT'); }, targetTab: 'tab-sync' },
|
||||
{ icon: '⚙️', get title() { return getMessage('ONBOARDING_4_TITLE'); }, get text() { return getMessage('ONBOARDING_4_TEXT'); }, targetTab: 'tab-settings' },
|
||||
{ icon: '🎉', get title() { return getMessage('ONBOARDING_5_TITLE'); }, get text() { return getMessage('ONBOARDING_5_TEXT'); }, targetTab: 'tab-room' }
|
||||
{
|
||||
icon: '👋',
|
||||
get title() { return getMessage('ONBOARDING_1_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_1_TEXT'); },
|
||||
targetTab: 'tab-room',
|
||||
targetSelector: null
|
||||
},
|
||||
{
|
||||
icon: '🏠',
|
||||
get title() { return getMessage('ONBOARDING_2_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_2_TEXT'); },
|
||||
targetTab: 'tab-room',
|
||||
targetSelector: '#createRoomBtn'
|
||||
},
|
||||
{
|
||||
icon: '🎬',
|
||||
get title() { return getMessage('ONBOARDING_3_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_3_TEXT'); },
|
||||
targetTab: 'tab-sync',
|
||||
targetSelector: '#targetTab'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
get title() { return getMessage('ONBOARDING_4_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_4_TEXT'); },
|
||||
targetTab: 'tab-settings',
|
||||
targetSelector: '#username'
|
||||
},
|
||||
{
|
||||
icon: '🍿',
|
||||
get title() { return getMessage('ONBOARDING_5_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_5_TEXT'); },
|
||||
targetTab: 'tab-room',
|
||||
targetSelector: null
|
||||
}
|
||||
];
|
||||
|
||||
function showSelectVideoHint() {
|
||||
const hint = document.getElementById('targetTabHint');
|
||||
if (hint && !elements.targetTab.value) {
|
||||
hint.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
let onboardingStep = 0;
|
||||
let onboardingTimeout = null;
|
||||
|
||||
function showOnboarding() {
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
if (!overlay) return;
|
||||
document.body.style.minHeight = '400px';
|
||||
overlay.style.display = 'flex';
|
||||
document.body.style.minHeight = '420px';
|
||||
overlay.style.display = 'block';
|
||||
onboardingStep = 0;
|
||||
renderOnboardingStep();
|
||||
}
|
||||
|
||||
function positionSpotlightAndCard(targetEl) {
|
||||
const spotlight = document.getElementById('onboarding-spotlight');
|
||||
const card = document.getElementById('onboarding-card');
|
||||
const arrow = document.getElementById('onboarding-arrow');
|
||||
if (!spotlight || !card || !arrow) return;
|
||||
|
||||
spotlight.style.display = 'block';
|
||||
card.style.display = 'block';
|
||||
|
||||
// Force a reflow
|
||||
card.getBoundingClientRect();
|
||||
spotlight.getBoundingClientRect();
|
||||
|
||||
const targetRect = targetEl.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
const sLeft = targetRect.left - pad;
|
||||
const sTop = targetRect.top - pad;
|
||||
const sWidth = targetRect.width + (pad * 2);
|
||||
const sHeight = targetRect.height + (pad * 2);
|
||||
|
||||
spotlight.style.left = `${sLeft}px`;
|
||||
spotlight.style.top = `${sTop}px`;
|
||||
spotlight.style.width = `${sWidth}px`;
|
||||
spotlight.style.height = `${sHeight}px`;
|
||||
spotlight.style.opacity = '1';
|
||||
|
||||
const cardWidth = 280;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
let cLeft = targetRect.left + (targetRect.width - cardWidth) / 2;
|
||||
cLeft = Math.max(12, Math.min(320 - cardWidth - 12, cLeft));
|
||||
|
||||
const cardHeight = card.offsetHeight || 150;
|
||||
let cTop = targetRect.bottom + 14;
|
||||
let arrowDir = 'up';
|
||||
|
||||
if (cTop + cardHeight > viewportHeight - 12) {
|
||||
cTop = targetRect.top - cardHeight - 14;
|
||||
arrowDir = 'down';
|
||||
}
|
||||
|
||||
if (cTop < 12) {
|
||||
cTop = targetRect.bottom + 14;
|
||||
arrowDir = 'up';
|
||||
}
|
||||
|
||||
card.style.left = `${cLeft}px`;
|
||||
card.style.top = `${cTop}px`;
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'scale(1)';
|
||||
|
||||
arrow.style.left = '';
|
||||
arrow.style.right = '';
|
||||
arrow.style.top = '';
|
||||
arrow.style.bottom = '';
|
||||
arrow.style.borderColor = 'transparent';
|
||||
|
||||
const targetCenter = targetRect.left + (targetRect.width / 2);
|
||||
const arrowLeft = targetCenter - cLeft - 6;
|
||||
arrow.style.left = `${Math.max(12, Math.min(cardWidth - 24, arrowLeft))}px`;
|
||||
|
||||
if (arrowDir === 'up') {
|
||||
arrow.style.top = '-12px';
|
||||
arrow.style.borderBottomColor = 'var(--card)';
|
||||
} else {
|
||||
arrow.style.bottom = '-12px';
|
||||
arrow.style.borderTopColor = 'var(--card)';
|
||||
}
|
||||
}
|
||||
|
||||
function centerCardFallback() {
|
||||
const spotlight = document.getElementById('onboarding-spotlight');
|
||||
const card = document.getElementById('onboarding-card');
|
||||
const arrow = document.getElementById('onboarding-arrow');
|
||||
if (!card) return;
|
||||
|
||||
if (spotlight) {
|
||||
spotlight.style.display = 'none';
|
||||
spotlight.style.opacity = '0';
|
||||
}
|
||||
if (arrow) {
|
||||
arrow.style.borderColor = 'transparent';
|
||||
}
|
||||
|
||||
card.style.display = 'block';
|
||||
|
||||
const cardWidth = 280;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
const cLeft = (320 - cardWidth) / 2;
|
||||
const cTop = (viewportHeight - (card.offsetHeight || 150)) / 2;
|
||||
|
||||
card.style.left = `${cLeft}px`;
|
||||
card.style.top = `${cTop}px`;
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'scale(1)';
|
||||
}
|
||||
|
||||
function renderOnboardingStep() {
|
||||
if (onboardingTimeout) clearTimeout(onboardingTimeout);
|
||||
|
||||
const step = onboardingSteps[onboardingStep];
|
||||
const icon = document.getElementById('onboarding-icon');
|
||||
const title = document.getElementById('onboarding-title');
|
||||
const text = document.getElementById('onboarding-text');
|
||||
const nextBtn = document.getElementById('onboarding-next');
|
||||
const dots = document.getElementById('onboarding-dots');
|
||||
if (!icon || !title || !text || !nextBtn || !dots) return;
|
||||
const stepIndicator = document.getElementById('onboarding-step-indicator');
|
||||
const progressBar = document.getElementById('onboarding-progress-bar');
|
||||
|
||||
if (!icon || !title || !text || !nextBtn) return;
|
||||
|
||||
icon.textContent = step.icon;
|
||||
title.textContent = step.title;
|
||||
text.textContent = step.text;
|
||||
|
||||
if (stepIndicator) {
|
||||
stepIndicator.textContent = `Step ${onboardingStep + 1} of ${onboardingSteps.length}`;
|
||||
}
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${((onboardingStep + 1) / onboardingSteps.length) * 100}%`;
|
||||
}
|
||||
|
||||
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1
|
||||
? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!')
|
||||
: getMessage('BTN_ONBOARDING_NEXT');
|
||||
|
||||
if (step.targetTab) {
|
||||
const tabBtn = document.querySelector(`.tab-btn[data-tab="${step.targetTab}"]`);
|
||||
if (tabBtn) tabBtn.click();
|
||||
@@ -1943,31 +2326,53 @@ function renderOnboardingStep() {
|
||||
if (syncActive) syncActive.style.display = 'block';
|
||||
if (syncInactive) syncInactive.style.display = 'none';
|
||||
} else {
|
||||
// Restore actual lock state when on other tabs so we don't leave it unlocked
|
||||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||||
if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none';
|
||||
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
dots.replaceChildren();
|
||||
onboardingSteps.forEach((_, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = `width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};`;
|
||||
dots.appendChild(dot);
|
||||
});
|
||||
const spotlight = document.getElementById('onboarding-spotlight');
|
||||
const card = document.getElementById('onboarding-card');
|
||||
if (spotlight) spotlight.style.opacity = '0';
|
||||
if (card) {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.95)';
|
||||
}
|
||||
|
||||
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1 ? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!') : getMessage('BTN_ONBOARDING_NEXT');
|
||||
onboardingTimeout = setTimeout(() => {
|
||||
const targetEl = step.targetSelector ? document.querySelector(step.targetSelector) : null;
|
||||
if (targetEl && targetEl.offsetParent !== null) {
|
||||
positionSpotlightAndCard(targetEl);
|
||||
} else {
|
||||
centerCardFallback();
|
||||
}
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function completeOnboarding() {
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
document.body.style.minHeight = '';
|
||||
chrome.storage.sync.set({ onboardingComplete: true });
|
||||
if (onboardingTimeout) clearTimeout(onboardingTimeout);
|
||||
|
||||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||||
toggleUIState(inRoom);
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
const spotlight = document.getElementById('onboarding-spotlight');
|
||||
const card = document.getElementById('onboarding-card');
|
||||
|
||||
if (card) {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.9)';
|
||||
}
|
||||
if (spotlight) {
|
||||
spotlight.style.opacity = '0';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
document.body.style.minHeight = '';
|
||||
chrome.storage.sync.set({ onboardingComplete: true });
|
||||
|
||||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||||
toggleUIState(inRoom);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
document.getElementById('onboarding-next')?.addEventListener('click', () => {
|
||||
@@ -1984,3 +2389,8 @@ document.getElementById('onboarding-skip')?.addEventListener('click', completeOn
|
||||
document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'onboarding-overlay') completeOnboarding();
|
||||
});
|
||||
|
||||
elements.restartTourBtn?.addEventListener('click', () => {
|
||||
onboardingStep = 0;
|
||||
showOnboarding();
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.0.5",
|
||||
"version": "2.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "2.0.5",
|
||||
"version": "2.2.4",
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -28,9 +28,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -45,9 +45,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -62,9 +62,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -79,9 +79,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -96,9 +96,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -113,9 +113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -130,9 +130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -147,9 +147,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -164,9 +164,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -181,9 +181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -198,9 +198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -215,9 +215,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -232,9 +232,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -249,9 +249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -266,9 +266,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -283,9 +283,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -300,9 +300,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -317,9 +317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -334,9 +334,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -351,9 +351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -368,9 +368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -385,9 +385,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -402,9 +402,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -419,9 +419,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -436,9 +436,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -453,9 +453,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1835,9 +1835,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1848,32 +1848,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.0.5",
|
||||
"version": "2.4.3",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js",
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node scripts/verify-release.mjs",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This directory contains utility scripts for the KoalaSync development workflow.
|
||||
|
||||
## build-extension.js
|
||||
## build-extension.cjs
|
||||
|
||||
The primary build tool for KoalaSync. This Node.js script automates two critical tasks:
|
||||
|
||||
@@ -15,7 +15,7 @@ The primary build tool for KoalaSync. This Node.js script automates two critical
|
||||
From the **repository root**, run:
|
||||
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
node scripts/build-extension.cjs
|
||||
```
|
||||
|
||||
### Why this script exists
|
||||
|
||||
@@ -38,7 +38,7 @@ const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
|
||||
|
||||
// Helper to copy files, ignoring manifest.json and manifest.base.json
|
||||
// Also injects shared constants into content.js
|
||||
function copyExtensionFiles(targetDir) {
|
||||
function copyExtensionFiles(targetDir, browserName) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Read master constants for injection
|
||||
@@ -77,7 +77,7 @@ function copyExtensionFiles(targetDir) {
|
||||
const eStart = '// --- SHARED_EVENTS_INJECT_START ---';
|
||||
const eEnd = '// --- SHARED_EVENTS_INJECT_END ---';
|
||||
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.js\n const EVENTS = ${eventsObject};\n ${eEnd}`;
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n ${eEnd}`;
|
||||
|
||||
if (ePattern.test(content)) {
|
||||
content = content.replace(ePattern, eRep);
|
||||
@@ -97,8 +97,49 @@ function copyExtensionFiles(targetDir) {
|
||||
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
|
||||
}
|
||||
|
||||
// 3. Inject Episode Utils
|
||||
const euStart = '// --- SHARED_EPISODE_UTILS_INJECT_START ---';
|
||||
const euEnd = '// --- SHARED_EPISODE_UTILS_INJECT_END ---';
|
||||
const euPattern = new RegExp(euStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]+?' + euEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const euPath = path.join(rootDir, 'extension', 'episode-utils.js');
|
||||
if (fs.existsSync(euPath)) {
|
||||
const euContent = fs.readFileSync(euPath, 'utf8');
|
||||
const stripped = euContent
|
||||
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
|
||||
.replace(/export function /g, 'function ')
|
||||
.trim();
|
||||
const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`;
|
||||
if (euPattern.test(content)) {
|
||||
content = content.replace(euPattern, euRep);
|
||||
} else {
|
||||
console.warn('⚠ WARNING: Episode utils markers not found in content.js');
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log('✓ Injected shared constants into content.js');
|
||||
} else if (item === 'background.js') {
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
|
||||
// 3. Inject Uninstall URL Constants
|
||||
const uStart = '// --- UNINSTALL_URL_INJECT_START ---';
|
||||
const uEnd = '// --- UNINSTALL_URL_INJECT_END ---';
|
||||
const uPattern = new RegExp(`${uStart}[\\s\\S]+?${uEnd}`);
|
||||
const placeholderUrl = "https://bye.koalastuff.net/c/camp_99ztjRVbK1BNN2RU";
|
||||
|
||||
let uRep = `${uStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n`;
|
||||
uRep += ` const UNINSTALL_URL = "${placeholderUrl}";\n`;
|
||||
uRep += ` const BROWSER_TYPE = "${browserName}";\n`;
|
||||
uRep += ` ${uEnd}`;
|
||||
|
||||
if (uPattern.test(content)) {
|
||||
content = content.replace(uPattern, uRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Uninstall URL markers not found in background.js');
|
||||
}
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
@@ -127,7 +168,7 @@ async function buildBrowser(browserName, manifestModifier) {
|
||||
const browserDistDir = path.join(distDir, browserName);
|
||||
|
||||
// 1. Copy files
|
||||
copyExtensionFiles(browserDistDir);
|
||||
copyExtensionFiles(browserDistDir, browserName);
|
||||
|
||||
// 2. Modify and write manifest
|
||||
const browserManifest = manifestModifier(JSON.parse(JSON.stringify(baseManifest)));
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sourcePath = path.join(repoRoot, 'extension/audio-options.js');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8')
|
||||
.replace("import { loadLocale, translateDOM, getSystemLanguage } from './i18n.js';", '')
|
||||
.replace(/init\(\)\.catch[\s\S]*?;\n?$/, '');
|
||||
|
||||
function makeInput(overrides = {}) {
|
||||
return {
|
||||
checked: false,
|
||||
value: '',
|
||||
dataset: {},
|
||||
addEventListener() {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const rows = [
|
||||
['threshold', '-60', '0', '1', false],
|
||||
['knee', '0', '40', '1', false],
|
||||
['ratio', '1', '20', '0.5', false],
|
||||
['attack', '0', '1', '0.001', true],
|
||||
['release', '0', '1', '0.005', true]
|
||||
].map(([param, min, max, step, ms]) => {
|
||||
const range = makeInput({ value: '0', min, max, step });
|
||||
const number = makeInput({ value: '0', min: ms ? '0' : min, max: ms ? '1000' : max, step, dataset: ms ? { msInput: 'true' } : {} });
|
||||
return {
|
||||
dataset: { param },
|
||||
querySelector(selector) {
|
||||
return selector === 'input[type="range"]' ? range : number;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
loadLocale: async () => {},
|
||||
translateDOM: () => {},
|
||||
getSystemLanguage: () => 'en',
|
||||
chrome: {
|
||||
storage: {
|
||||
sync: {
|
||||
get: async () => ({}),
|
||||
set: () => {},
|
||||
},
|
||||
local: {
|
||||
get: async () => ({}),
|
||||
set: () => {},
|
||||
},
|
||||
onChanged: {
|
||||
addListener: () => {}
|
||||
}
|
||||
}
|
||||
},
|
||||
document: {
|
||||
getElementById: () => makeInput(),
|
||||
querySelectorAll: (selector) => selector === '.control-row' ? rows : [makeInput({ value: 'recommended' })]
|
||||
},
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(`${source}
|
||||
globalThis.__audioSettingsTest = {
|
||||
mergeAudioSettings,
|
||||
getParamValue,
|
||||
setCustomParam,
|
||||
get currentSettings() { return currentSettings; }
|
||||
};`, sandbox, { filename: sourcePath });
|
||||
|
||||
const helpers = sandbox.__audioSettingsTest;
|
||||
|
||||
assert.doesNotThrow(() => helpers.mergeAudioSettings(null), 'mergeAudioSettings tolerates null storage values');
|
||||
assert.doesNotThrow(() => helpers.mergeAudioSettings('bad'), 'mergeAudioSettings tolerates non-object storage values');
|
||||
|
||||
assert.equal(helpers.getParamValue('threshold', '-999'), -60, 'threshold clamps to minimum');
|
||||
assert.equal(helpers.getParamValue('threshold', '999'), 0, 'threshold clamps to maximum');
|
||||
assert.equal(helpers.getParamValue('knee', '-1'), 0, 'knee clamps to minimum');
|
||||
assert.equal(helpers.getParamValue('knee', '100'), 40, 'knee clamps to maximum');
|
||||
assert.equal(helpers.getParamValue('ratio', '0'), 1, 'ratio clamps to minimum');
|
||||
assert.equal(helpers.getParamValue('ratio', '999'), 20, 'ratio clamps to maximum');
|
||||
assert.equal(helpers.getParamValue('attack', '-1', true), 0, 'attack ms input clamps to minimum seconds');
|
||||
assert.equal(helpers.getParamValue('attack', '5000', true), 1, 'attack ms input clamps to maximum seconds');
|
||||
assert.equal(helpers.getParamValue('release', '-1', true), 0, 'release ms input clamps to minimum seconds');
|
||||
assert.equal(helpers.getParamValue('release', '5000', true), 1, 'release ms input clamps to maximum seconds');
|
||||
|
||||
helpers.setCustomParam('threshold', 999);
|
||||
assert.equal(helpers.currentSettings.compressor.customParams.threshold, 0, 'setCustomParam stores clamped values');
|
||||
|
||||
console.log('audio settings tests passed');
|
||||