Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a6be6b2670 | |||
| b51e66d824 | |||
| e034882975 | |||
| e53a93829e | |||
| e3dbf2b8a2 | |||
| 595ea297f5 | |||
| a948780745 | |||
| 8c899a6469 | |||
| 4f4cdcd8c0 | |||
| 602be3a724 | |||
| bb7bd21102 | |||
| 543bfe074d | |||
| 81c50eff16 | |||
| bf0fb5741f | |||
| 6d2355f404 | |||
| 732d585273 | |||
| 666808f876 | |||
| 3ec9812265 | |||
| 64f3c5eefd | |||
| 3fe074b308 | |||
| a06cca8b9d | |||
| c4fc3ab53b | |||
| 3f3fea58ed | |||
| 014169f84e | |||
| 9c61abee03 | |||
| 2f11c60307 | |||
| 27301f8746 | |||
| 030b839b12 | |||
| 5c805bcafa | |||
| eb83d9148e | |||
| 290360387f | |||
| c56e404c13 | |||
| e519ea2302 | |||
| 5a75bae8d0 | |||
| fc3f4f38ff | |||
| f69da7542e | |||
| aba05060b9 | |||
| 0b986e6e13 | |||
| b23ce8ee7d | |||
| 3195bd0089 | |||
| 1c1778d265 | |||
| 0262d9c1ad | |||
| f23c329709 | |||
| 78a165d368 | |||
| 63b5ef0ffd | |||
| a26b07cb5c | |||
| a445759dd7 | |||
| 1e883d8ee4 | |||
| be574e0e25 | |||
| ebf3178e32 | |||
| ba96cf2765 | |||
| d026ed891a |
@@ -0,0 +1,2 @@
|
||||
# These are supported funding model platforms
|
||||
ko_fi: koaladev
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Create a report to help us improve KoalaSync
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ Required:** Before submitting, open the KoalaSync **Status** tab in the extension popup and click **"Copy Logs"**. Paste the full output below — it contains essential system info, connection state, and debug data needed to diagnose your issue.
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Copy Logs Output</b></summary>
|
||||
|
||||
<!-- Paste the copied logs here -->
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Describe the Bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
### To Reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
A clear and concise description of what actually happened.
|
||||
|
||||
### Screenshots / Screen Recordings
|
||||
|
||||
If applicable, add screenshots or recordings to help explain your problem.
|
||||
|
||||
### Environment
|
||||
|
||||
- **Browser:** (e.g. Chrome 125, Firefox 128)
|
||||
- **Extension Version:** (visible at the bottom of the Settings tab)
|
||||
- **OS:** (e.g. Windows 11, macOS 14.5)
|
||||
- **Self-Hosted Server?:** (yes / no — if yes, provide server version)
|
||||
- **Website/Platform:** (e.g. YouTube, Netflix, Twitch, Jellyfin, Emby)
|
||||
|
||||
### Additional Context
|
||||
|
||||
Add any other context about the problem here (e.g. network setup, VPN usage, multiple monitors, etc.).
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: 🚀 Feature Request
|
||||
about: Suggest a new feature or enhancement for KoalaSync
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### Problem Description
|
||||
|
||||
A clear and concise description of the problem or limitation you're encountering. What's missing or what could be improved?
|
||||
|
||||
*As a user, I want to ... so that ...*
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Describe the feature you'd like to see. How should it work? Be as specific as possible.
|
||||
|
||||
### Use Cases & Benefits
|
||||
|
||||
- **When would you use this?** (e.g., specific websites, workflows, setups)
|
||||
- **What value does it add?** (e.g., saves time, enables new functionality, improves UX)
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
What workarounds or alternative approaches have you tried? Are there other ways to achieve a similar result?
|
||||
|
||||
### Additional Context
|
||||
|
||||
Add any other context, sketches, mockups, or references (e.g., links to similar features in other projects).
|
||||
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
Thanks for contributing to KoalaSync!
|
||||
|
||||
Please read the CONTRIBUTING.md and CODE_OF_CONDUCT.md before submitting.
|
||||
By submitting this PR, you agree to abide by our code of conduct.
|
||||
|
||||
Use conventional commits for the title: feat:, fix:, docs:, refactor:, chore:
|
||||
-->
|
||||
|
||||
### Description
|
||||
|
||||
<!-- What does this PR do and why? Link to relevant issue or motivation. -->
|
||||
|
||||
Closes #
|
||||
|
||||
### Type of Change
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that alters existing behavior)
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] Documentation update
|
||||
- [ ] Build, dependencies, or CI
|
||||
|
||||
### How Has This Been Tested?
|
||||
|
||||
<!-- Describe the tests you ran and the environments (browsers, OS, etc.) -->
|
||||
|
||||
- [ ] Tested on Chrome
|
||||
- [ ] Tested on Firefox
|
||||
- [ ] `npm run lint` passes with zero errors and zero warnings
|
||||
- [ ] `node -c` passes on all modified `.js` files
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] 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
|
||||
- [ ] No new warnings, secrets, or hardcoded credentials introduced
|
||||
|
||||
### Additional Context
|
||||
|
||||
<!-- Screenshots, migration notes, performance data, etc. -->
|
||||
@@ -0,0 +1,131 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainer at **koaladev@koalamail.rocks**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All project maintainers are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Project maintainers will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from project maintainers, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Thanks for your interest in improving KoalaSync. All contributions are welcome — from bug reports and translations to core protocol changes.
|
||||
|
||||
Please note that by participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
---
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.2.0 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.
|
||||
@@ -31,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.
|
||||
- **Audio Compressor**: Built-in Web Audio API dynamic range compression with presets (Recommended, Dynamic Range, Vocal Enhancement, Smooth) and fully customizable controls.
|
||||
|
||||
---
|
||||
|
||||
@@ -79,14 +82,14 @@ 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"}`.
|
||||
|
||||
@@ -95,7 +98,7 @@ To verify your relay is reachable from outside, visit `https://your-domain.com`
|
||||
### 🌐 Localization & Translations
|
||||
|
||||
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`).
|
||||
- **Available Languages**: Support is included for 13 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`).
|
||||
- **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.
|
||||
|
||||
@@ -104,17 +107,21 @@ Both the official KoalaSync website and the **v2.0 Browser Extension** feature f
|
||||
|
||||
### 📖 Documentation & Links
|
||||
|
||||
- **[TESTED_SERVICES.md](TESTED_SERVICES.md)**: Detailed compatibility matrix of tested streaming platforms and known limitations.
|
||||
- **[CHANGELOG.md](docs/CHANGELOG.md)**: Full version history for the extension and relay server.
|
||||
- **[TESTED_SERVICES.md](docs/TESTED_SERVICES.md)**: Detailed compatibility matrix of tested streaming platforms and known limitations.
|
||||
- **[TRANSLATION.md](website/TRANSLATION.md)**: Translation and localization guide for contributors.
|
||||
- **[PRIVACY.md](PRIVACY.md)**: Data Handling and Privacy Policy.
|
||||
- **[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.
|
||||
- **[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>Built with ❤️ by <a href="https://github.com/Shik3i">Shik3i</a>. KoalaSync is Open Source under the <a href="LICENSE">MIT License</a>.</sub>
|
||||
<sub><a href="https://ko-fi.com/koaladev"><img src="https://img.shields.io/badge/Ko--Fi-Support-FF5E5B?logo=ko-fi&logoColor=white" alt="Ko-Fi"></a></sub>
|
||||
<sub><a href="https://gitgem.org/github/Shik3i/KoalaSync"><img src="https://gitgem.org/api/badge/github/Shik3i/KoalaSync.svg" alt="GitGem Badge" /></a></sub>
|
||||
</div>
|
||||
|
||||
@@ -73,8 +73,9 @@ Encrypt sensitive findings with our PGP key (available on request).
|
||||
KoalaSync's security is grounded in its architecture:
|
||||
|
||||
- **RAM-only relay**: No database, no persistent logs. All session data evaporates on disconnect.
|
||||
- **bcrypt room passwords**: Plaintext passwords never stored. Brute-force protection: 5 attempts → 15-minute IP lockout.
|
||||
- **Rate limiting**: Connection rate (IP-based, 60s window) and event rate (per-socket, 10s window).
|
||||
- **Keyed SHA-256 room password hashes**: Plaintext passwords are never stored. Room passwords are held only as in-memory HMAC-SHA256 hashes for the short room lifetime, with brute-force protection: 5 attempts → 15-minute IP lockout.
|
||||
- **Rate limiting**: Connection rate (IP-based, 60s window), health endpoint rate (10 requests/minute/IP), wrong admin-metrics bearer attempts (5 requests/minute/IP), and event rate (per-socket, 10s window). Health-style JSON responses are cached server-side for 60 seconds and refreshed lazily on request.
|
||||
- **Reverse proxy boundary**: The relay trusts one proxy hop for client IP detection. In production, keep the Node server reachable only through Caddy or another trusted reverse proxy.
|
||||
- **URL-hash credential isolation**: Invitation credentials live in the URL fragment (`#join:...`) — never sent to the web server.
|
||||
- **Strict CSP**: `default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'`.
|
||||
- **No third-party requests**: Zero CDNs, fonts, analytics, or external scripts.
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 889 KiB |
|
Before Width: | Height: | Size: 462 KiB |
|
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,37 @@
|
||||
KoalaSync: The Privacy-First Watch Party Extension
|
||||
|
||||
Private watch parties for Emby, Jellyfin, Plex, Netflix, YouTube & more. No account needed. Real-time sync. Supports 13 languages. 100% free.
|
||||
|
||||
Whether you're binge-watching Netflix, streaming from your own self-hosted Emby, Jellyfin, or Plex servers, or watching on virtually any other website with a video player — KoalaSync keeps everyone perfectly in sync.
|
||||
Lightweight, open-source, and built for absolute data sovereignty with a clean, minimalist UI.
|
||||
|
||||
✨ CORE FEATURES
|
||||
|
||||
• No Account Needed: Just create a room and share the link. No emails, no passwords, no hassle. A random nickname is assigned automatically, or pick your own.
|
||||
• Universal HTML5 Sync: Flawlessly synchronizes play, pause, and seeking on Netflix, Jellyfin, Emby, YouTube, and almost any website hosting a standard HTML5 <video> tag.
|
||||
• Smart Binge-Watching (Auto-Sync): When a new episode loads, KoalaSync automatically pauses playback in a smart lobby until everyone's video is ready. No spoilers, no one left behind.
|
||||
• Fully Localized: Enjoy a native experience with a user interface fully translated into 13 different languages.
|
||||
• One-Click Invitations: Generate a smart invite link. Once clicked, the extension instantly configures room credentials and server settings for your guests.
|
||||
• Zero-Latency Protocol: Powered by a custom WebSocket architecture designed for real-time coordination without buffering loops.
|
||||
|
||||
🛡️ UNCOMPROMISING PRIVACY & SECURITY
|
||||
|
||||
No data collection. No compromises.
|
||||
• 100% Anonymous: Zero tracking, zero analytics, zero telemetry.
|
||||
• Ready Out-Of-The-Box: No technical knowledge needed. Install and start watching immediately using the official public server.
|
||||
• RAM-Only Public Server: The official relay server operates entirely in volatile RAM. No databases, no logs, no watch history — nothing persists. It also features built-in rate-limiting and brute-force protection to prevent room-id spamming.
|
||||
• Self-Hostable: Take total control by deploying a private relay server in seconds via Docker. Optional, never required.
|
||||
|
||||
🚀 HOW IT WORKS
|
||||
|
||||
1. Install KoalaSync and click "Create Room" to spin up a secure lobby.
|
||||
2. Copy the invite link and send it to your friends.
|
||||
3. Open your favorite streaming site and set the video tab as the active target.
|
||||
4. Press play — everyone stays perfectly in sync.
|
||||
|
||||
💻 OPEN SOURCE
|
||||
|
||||
KoalaSync was built by a solo developer who needed a fast, secure way to watch movies with friends — and builds tools worth using. The code is fully transparent under the MIT license: audit it, fork it, improve it. 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
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -34,14 +34,14 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
|
||||
|
||||
## 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.js`) 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,13 +100,23 @@ 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.
|
||||
|
||||
### Releasing a New Version (CRITICAL WORKFLOW FOR AI AGENTS)
|
||||
> [!CAUTION]
|
||||
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
|
||||
> The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json`. You do NOT need to manually bump version numbers.
|
||||
>
|
||||
> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures.
|
||||
> - **Website Versioning**: **NEVER** manually modify the version fallback strings in `website/index.html`. The website dynamically fetches the latest version and release date from `website/version.json` at runtime using `website/app.js`. Manual bumps in the HTML file are completely redundant and should be avoided.
|
||||
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
|
||||
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
|
||||
@@ -58,7 +58,7 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
|
||||
## 7. Security & Stability
|
||||
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
|
||||
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
|
||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS. Real client IP extracted via `x-forwarded-for` header behind proxies/CDNs.
|
||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or simple DoS. Public health endpoints are limited to 10 requests/minute/IP and cached server-side for 60 seconds, wrong admin-metrics bearer attempts to 5 requests/minute/IP, and room discovery to one request every 10 seconds per socket. Real client IP is taken from the trusted reverse proxy hop, so the Node port must stay private behind Caddy or another trusted proxy.
|
||||
- **Room Creation Lock**: Per-room mutex prevents race conditions when multiple peers join a new room simultaneously.
|
||||
- **CORS**: Allows `chrome-extension://` origins for WebSocket fallback compatibility.
|
||||
- **Message Buffer**: `maxHttpBufferSize` set to 4KB to accommodate large `JOIN_ROOM` payloads.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [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, makeup gain). 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
|
||||
- **Ko-Fi Support Links**: Static footer badges on the Settings and Status tabs linking to the developer's support page. README and website footer updated with Ko-Fi badge.
|
||||
|
||||
### 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.
|
||||
@@ -51,7 +51,7 @@ Click **"Create Room"** in the popup's Room tab:
|
||||
|
||||
4. **Server-side processing**:
|
||||
- All fields are **sanitized**: `roomId` is stripped of invalid characters and clamped to 64 chars; `peerId` clamped to 16 chars; `password` clamped to 128 chars; `username` clamped to 30 chars.
|
||||
- The server **hashes the password** with bcrypt and stores the hash in RAM (the plaintext is never stored).
|
||||
- The server **hashes the password** with a keyed SHA-256 HMAC and stores only that hash in RAM (the plaintext is never stored).
|
||||
- A new room object is created in memory with the peer's data.
|
||||
- The server responds with `ROOM_DATA` containing the list of peers in the room.
|
||||
|
||||
@@ -89,7 +89,7 @@ When your friend opens the link in their browser:
|
||||
- After 500ms, the page dispatches a `KOALASYNC_JOIN_REQUEST` custom DOM event with `{ roomId, password, useCustomServer, serverUrl }`.
|
||||
- `bridge.js` catches this event and forwards it to `background.js` via `chrome.runtime.sendMessage`.
|
||||
- `background.js` stores the credentials in `chrome.storage.sync` and emits `JOIN_ROOM` to the server.
|
||||
- The server validates the password against the stored bcrypt hash.
|
||||
- The server validates the password against the stored keyed SHA-256 HMAC hash.
|
||||
- On success, the server responds with `ROOM_DATA` and broadcasts `PEER_STATUS { status: 'joined' }` to all existing peers.
|
||||
- The join page updates to show "✅ Successfully joined!".
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
**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)
|
||||
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
|
||||
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
|
||||
- **Room Passwords**: If you set a room password, it is stored only as a secure **bcrypt hash** in RAM. The server never sees or stores your plaintext password.
|
||||
- **Room Passwords**: If you set a room password, it is stored only as an in-memory **keyed SHA-256 HMAC hash**. The server receives the plaintext password only during join validation, never stores it, and keeps only the hash for the short room lifetime.
|
||||
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
|
||||
|
||||
### Data Retention
|
||||
@@ -1,15 +1,14 @@
|
||||
# KoalaSync Roadmap
|
||||
|
||||
Dieses Dokument erfasst zukünftige technische Pläne und Optimierungen für das KoalaSync-System.
|
||||
This document tracks future technical plans and optimizations for the KoalaSync system.
|
||||
|
||||
---
|
||||
|
||||
## Geplante Optimierungen & Technische Roadmap
|
||||
## Planned Optimizations & Technical Roadmap
|
||||
|
||||
### 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.
|
||||
### 1. Split large JavaScript files (> 800 lines) into smaller modules
|
||||
* **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.
|
||||
|
||||
@@ -31,7 +31,7 @@ The build script performs the following actions:
|
||||
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
||||
- The version is defined in `shared/constants.js`.
|
||||
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
|
||||
- **Always run the build script** after bumping the version number to ensure both components are updated.
|
||||
- **Never manually bump version numbers**. The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Run the build script to synchronize other constant updates.
|
||||
|
||||
> [!CAUTION]
|
||||
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
|
||||
|
||||
@@ -11,7 +11,7 @@ This document tracks which streaming platforms and media servers have been teste
|
||||
| **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** | ❌ None | ❌ None | ❌ | Extension cannot detect video elements. Play/Pause do not work. Media title detection fails. DRM and custom player prevent script access. |
|
||||
| **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. |
|
||||
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
|
||||
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
|
||||
| **Vimeo** | Not tested | Not tested | Not tested | — |
|
||||
@@ -36,7 +36,8 @@ export default [
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
WebSocket: "readonly",
|
||||
self: "readonly"
|
||||
self: "readonly",
|
||||
process: "readonly"
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
|
||||
@@ -8,7 +8,8 @@ services:
|
||||
- PORT=3000 # Port KoalaSync listens on inside the container
|
||||
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
|
||||
- MAX_ROOMS=100 # Maximum number of rooms that can exist
|
||||
- MAX_PEERS_PER_ROOM=50 # Maximum number of peers allowed per room
|
||||
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
|
||||
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
|
||||
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
|
||||
networks: # Attaches the service to the networks listed below
|
||||
- caddy_net # Joins the pre-existing Caddy network for reverse proxying
|
||||
@@ -10,7 +10,8 @@ services:
|
||||
- PORT=3000 # Port KoalaSync listens on inside the container
|
||||
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
|
||||
- MAX_ROOMS=100 # Maximum number of rooms that can exist
|
||||
- MAX_PEERS_PER_ROOM=50 # Maximum number of peers allowed per room
|
||||
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
|
||||
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
|
||||
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
|
||||
networks: # Attaches the service to the networks listed below
|
||||
bond0_network: # Network name as referenced by the service
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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"
|
||||
|
||||
- name: koalasync_rate_limit_connections
|
||||
path: '{.rateLimitEntries.connections}'
|
||||
help: "Number of entries tracked in the connection rate limiter"
|
||||
|
||||
- name: koalasync_rate_limit_events
|
||||
path: '{.rateLimitEntries.events}'
|
||||
help: "Number of entries in the event rate limiter"
|
||||
|
||||
- name: koalasync_rate_limit_health
|
||||
path: '{.rateLimitEntries.health}'
|
||||
help: "Number of entries in the health rate limiter"
|
||||
|
||||
- name: koalasync_rate_limit_admin_metrics_auth
|
||||
path: '{.rateLimitEntries.adminMetricsAuth}'
|
||||
help: "Number of entries in the admin metrics auth rate limiter"
|
||||
|
||||
- name: koalasync_rate_limit_auth_failures
|
||||
path: '{.rateLimitEntries.authFailures}'
|
||||
help: "Number of entries in the authentication failures cache"
|
||||
|
||||
- name: koalasync_rate_limit_room_list
|
||||
path: '{.rateLimitEntries.roomList}'
|
||||
help: "Number of entries in the room list cooldown cache"
|
||||
@@ -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,6 +1,6 @@
|
||||
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';
|
||||
|
||||
|
||||
// --- State Management ---
|
||||
@@ -56,7 +56,7 @@ function ensureState() {
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount'
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
||||
@@ -80,6 +80,8 @@ function ensureState() {
|
||||
if (data.reconnectFailed !== undefined) reconnectFailed = data.reconnectFailed;
|
||||
if (data.reconnectStartTime) reconnectStartTime = data.reconnectStartTime;
|
||||
if (data.reconnectAttempts !== undefined) reconnectAttempts = data.reconnectAttempts;
|
||||
if (data.roomIdleSince !== undefined) roomIdleSince = data.roomIdleSince;
|
||||
if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt;
|
||||
|
||||
// Recover Force Sync Timeout
|
||||
if (data.forceSyncDeadline) {
|
||||
@@ -139,9 +141,12 @@ let reconnectStartTime = null;
|
||||
let reconnectFailed = false;
|
||||
let reconnectAttempts = 0;
|
||||
let currentServerUrl = null;
|
||||
let roomIdleSince = null;
|
||||
let lastContentHeartbeatAt = null;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Force Sync Coordination
|
||||
let isForceSyncInitiator = false;
|
||||
@@ -225,35 +230,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') {
|
||||
@@ -304,6 +312,8 @@ function forceDisconnect() {
|
||||
isNamespaceJoined = false;
|
||||
isForceSyncInitiator = false;
|
||||
expectedAcksCount = 0;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
forceSyncAcks.clear();
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({
|
||||
@@ -312,7 +322,9 @@ function forceDisconnect() {
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0,
|
||||
eventQueue: [],
|
||||
episodeLobby: null
|
||||
episodeLobby: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null
|
||||
}).catch(() => {});
|
||||
if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
@@ -322,6 +334,63 @@ function forceDisconnect() {
|
||||
broadcastConnectionStatus('disconnected');
|
||||
}
|
||||
|
||||
function persistRoomIdleState() {
|
||||
chrome.storage.session.set({ roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
||||
}
|
||||
|
||||
function markRoomUseful() {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = Date.now();
|
||||
persistRoomIdleState();
|
||||
}
|
||||
|
||||
function markRoomPotentiallyIdle() {
|
||||
if (!currentRoom) {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
persistRoomIdleState();
|
||||
return;
|
||||
}
|
||||
if (!roomIdleSince) {
|
||||
roomIdleSince = Date.now();
|
||||
persistRoomIdleState();
|
||||
}
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle() {
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
}
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
clearEpisodeLobbyState();
|
||||
await chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
episodeLobby: null
|
||||
}).catch(() => {});
|
||||
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (isConnecting) return;
|
||||
isConnecting = true;
|
||||
@@ -391,7 +460,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());
|
||||
@@ -482,12 +551,15 @@ async function connect() {
|
||||
|
||||
socket.onerror = () => {
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog('WebSocket Error: Connection failed', 'error');
|
||||
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
|
||||
addLog('WebSocket Error: Connection failed', logType);
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
isConnecting = false;
|
||||
addLog(e.message, 'error');
|
||||
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
|
||||
const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error');
|
||||
addLog(errMsg, logType);
|
||||
broadcastConnectionStatus('disconnected');
|
||||
scheduleReconnect();
|
||||
}
|
||||
@@ -519,14 +591,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 = '';
|
||||
@@ -625,9 +693,14 @@ function addToHistory(action, senderId) {
|
||||
|
||||
// --- 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;
|
||||
markRoomPotentiallyIdle();
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
||||
|
||||
@@ -691,12 +764,8 @@ function handleServerEvent(event, data) {
|
||||
isConnecting = 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(['locale'], async (settings) => {
|
||||
const lang = settings.locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
chrome.notifications.create(`error_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
@@ -1027,14 +1096,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];
|
||||
@@ -1149,8 +1214,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
|
||||
}).catch(err => {
|
||||
if (retries >= 3) {
|
||||
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
|
||||
currentTabId = null;
|
||||
updateBadgeStatus();
|
||||
clearTargetTabForIdle();
|
||||
return;
|
||||
}
|
||||
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
|
||||
@@ -1164,8 +1228,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
|
||||
});
|
||||
} else {
|
||||
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
|
||||
currentTabId = null;
|
||||
updateBadgeStatus();
|
||||
clearTargetTabForIdle();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1181,6 +1244,15 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
connect();
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
const now = Date.now();
|
||||
const heartbeatAge = lastContentHeartbeatAt ? (now - lastContentHeartbeatAt) : Infinity;
|
||||
if (!currentTabId || heartbeatAge > 45000) {
|
||||
markRoomPotentiallyIdle();
|
||||
}
|
||||
if (roomIdleSince && Date.now() - roomIdleSince >= ROOM_IDLE_AUTO_LEAVE_MS) {
|
||||
await leaveRoomAfterIdleGrace('Left room after 2 hours without a selected video heartbeat.');
|
||||
return;
|
||||
}
|
||||
// Heartbeat Logic: Always include identity metadata
|
||||
const settings = await getSettings();
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
@@ -1220,6 +1292,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);
|
||||
@@ -1227,6 +1320,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
});
|
||||
|
||||
async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (!message) return;
|
||||
await ensureState();
|
||||
|
||||
if (message.type === 'CONNECT') {
|
||||
@@ -1276,14 +1370,18 @@ 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
|
||||
});
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
resetAudioProcessingInTab(currentTabId);
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
|
||||
updateBadgeStatus();
|
||||
|
||||
@@ -1297,6 +1395,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
@@ -1319,7 +1421,7 @@ 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({
|
||||
chrome.storage.local.set({
|
||||
roomId,
|
||||
password,
|
||||
useCustomServer: !!useCustomServer,
|
||||
@@ -1376,15 +1478,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) {
|
||||
@@ -1489,6 +1595,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
markRoomUseful();
|
||||
getSettings().then(settings => {
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||
emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
@@ -1514,18 +1621,30 @@ 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;
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle });
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
}
|
||||
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' });
|
||||
@@ -1549,7 +1668,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' });
|
||||
@@ -1635,6 +1754,17 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'sync' || !changes.audioSettings) return;
|
||||
await ensureState();
|
||||
if (!currentTabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(currentTabId, {
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: changes.audioSettings.newValue
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// Tab removal listener
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
await ensureState();
|
||||
@@ -1642,7 +1772,9 @@ chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
const wasInRoom = !!currentRoom;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
chrome.storage.session.set({ 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');
|
||||
|
||||
@@ -1684,7 +1816,9 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).catch(() => {});
|
||||
})
|
||||
.then(() => applyAudioSettingsToTab(tabId))
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,13 +62,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;
|
||||
}
|
||||
});
|
||||
@@ -78,21 +100,181 @@
|
||||
}
|
||||
|
||||
// --- Helper: find the best video element on the page ---
|
||||
// Prefers larger, visible videos over tiny preview/trailer elements.
|
||||
function findVideo(root = document) {
|
||||
const video = root.querySelector('video');
|
||||
if (video) return video;
|
||||
|
||||
// Optimize: scan only potential player, video, media, and stream hosts by matching typical keywords (case-insensitive)
|
||||
// or common custom element tags. This prevents recursive scanning of thousands of standard DOM nodes (div, span, a, etc.)
|
||||
// while guaranteeing 100% airtight compatibility with all video web components in the wild.
|
||||
const candidates = Array.from(root.querySelectorAll('video'));
|
||||
|
||||
// Scan likely media hosts even when light-DOM videos exist; many players
|
||||
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
|
||||
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
|
||||
for (const el of potentialHosts) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findVideo(el.shadowRoot);
|
||||
if (found) return found;
|
||||
if (found) candidates.push(found);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Multiple videos found → pick the best one
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
let best = null;
|
||||
let bestScore = -1;
|
||||
for (const v of candidates) {
|
||||
if (v.tagName !== 'VIDEO') continue;
|
||||
// Score: visible area + bonus for unmuted + bonus for longer duration
|
||||
const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0);
|
||||
const unmutedBonus = v.muted ? 0 : 100000;
|
||||
const durationBonus = (v.duration && isFinite(v.duration) ? v.duration : 0) * 100;
|
||||
const score = area + unmutedBonus + durationBonus;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = v;
|
||||
}
|
||||
}
|
||||
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(() => {});
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
@@ -107,8 +289,8 @@
|
||||
// Returns null if no episode pattern found.
|
||||
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);
|
||||
// S01E01 patterns (with any non-alphanumeric separator between season and E)
|
||||
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);
|
||||
@@ -242,8 +424,8 @@
|
||||
|
||||
try {
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
const isYouTube = host.includes('youtube.com');
|
||||
const isTwitch = host.includes('twitch.tv');
|
||||
const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com');
|
||||
const isTwitch = host === 'twitch.tv' || host.endsWith('.twitch.tv');
|
||||
|
||||
if (isYouTube) {
|
||||
const ytButton = document.querySelector('.ytp-play-button');
|
||||
@@ -325,12 +507,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;
|
||||
@@ -347,7 +546,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;
|
||||
}
|
||||
@@ -376,7 +575,11 @@
|
||||
_setSuppress('paused');
|
||||
_setSuppress('seek');
|
||||
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) {
|
||||
@@ -436,15 +639,15 @@
|
||||
|
||||
const platform = (() => {
|
||||
const h = window.location.hostname.toLowerCase();
|
||||
if (h.includes('youtube.com')) return 'YouTube';
|
||||
if (h.includes('twitch.tv')) return 'Twitch';
|
||||
if (h.includes('netflix.com')) return 'Netflix';
|
||||
if (h.includes('primevideo.com') || h.includes('amazon.')) return 'Prime Video';
|
||||
if (h.includes('disneyplus.com')) return 'Disney+';
|
||||
if (h.includes('hulu.com')) return 'Hulu';
|
||||
if (h.includes('max.com') || h.includes('hbomax.com')) return 'Max/HBO';
|
||||
if (h.includes('vimeo.com')) return 'Vimeo';
|
||||
if (h.includes('dailymotion.com')) return 'Dailymotion';
|
||||
if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube';
|
||||
if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch';
|
||||
if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix';
|
||||
if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video';
|
||||
if (h === 'disneyplus.com' || h.endsWith('.disneyplus.com')) return 'Disney+';
|
||||
if (h === 'hulu.com' || h.endsWith('.hulu.com')) return 'Hulu';
|
||||
if (h === 'hbomax.com' || h.endsWith('.hbomax.com') || h === 'max.com' || h.endsWith('.max.com')) return 'Max/HBO';
|
||||
if (h === 'vimeo.com' || h.endsWith('.vimeo.com')) return 'Vimeo';
|
||||
if (h === 'dailymotion.com' || h.endsWith('.dailymotion.com')) return 'Dailymotion';
|
||||
return 'Generic';
|
||||
})();
|
||||
|
||||
@@ -468,6 +671,24 @@
|
||||
return false;
|
||||
})();
|
||||
|
||||
// Build multi-video summary for debug reports
|
||||
const allVideos = [];
|
||||
const allVideoEls = document.querySelectorAll('video');
|
||||
for (let i = 0; i < allVideoEls.length; i++) {
|
||||
const v = allVideoEls[i];
|
||||
allVideos.push({
|
||||
index: i,
|
||||
width: v.videoWidth || v.offsetWidth || 0,
|
||||
height: v.videoHeight || v.offsetHeight || 0,
|
||||
muted: v.muted,
|
||||
paused: v.paused,
|
||||
duration: (v.duration && isFinite(v.duration)) ? Math.round(v.duration) : 0,
|
||||
readyState: v.readyState,
|
||||
src: (v.currentSrc || v.src || '').substring(0, 80),
|
||||
selected: v === video
|
||||
});
|
||||
}
|
||||
|
||||
if (video) {
|
||||
const dataAttributes = {};
|
||||
if (video.attributes) {
|
||||
@@ -522,7 +743,8 @@
|
||||
metadata,
|
||||
videoCount,
|
||||
inShadowDom,
|
||||
platform
|
||||
platform,
|
||||
allVideos
|
||||
});
|
||||
} else {
|
||||
sendResponse({
|
||||
@@ -530,6 +752,7 @@
|
||||
videoCount,
|
||||
inShadowDom,
|
||||
platform,
|
||||
allVideos,
|
||||
url: window.location.href,
|
||||
pageTitle: document.title,
|
||||
metadata: (navigator.mediaSession && navigator.mediaSession.metadata) ? {
|
||||
@@ -583,7 +806,7 @@
|
||||
mediaTitle: mediaTitle,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
// Trigger proactive heartbeat to push stabilized state
|
||||
scheduleProactiveHeartbeat();
|
||||
@@ -611,7 +834,10 @@
|
||||
});
|
||||
|
||||
// Reset on page hide/show (bfcache, tab discard)
|
||||
window.addEventListener('pagehide', () => { pageVisible = false; });
|
||||
window.addEventListener('pagehide', () => {
|
||||
pageVisible = false;
|
||||
closeAudioContext();
|
||||
});
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
// event.persisted is true ONLY when restored from bfcache, not on initial load
|
||||
if (event.persisted && !pageVisible) {
|
||||
@@ -678,6 +904,9 @@
|
||||
function setupListeners() {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
if (currentAudioVideo && currentAudioVideo !== video) {
|
||||
bypassCurrentAudioProcessing();
|
||||
}
|
||||
const existing = video._koalaHandlers;
|
||||
if (existing) {
|
||||
video.removeEventListener('play', existing.play);
|
||||
@@ -697,6 +926,8 @@
|
||||
if (!lastKnownMediaTitle) {
|
||||
lastKnownMediaTitle = getMediaTitle();
|
||||
}
|
||||
|
||||
if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,6 +942,7 @@
|
||||
if (!video && lastVideoSrc !== undefined) {
|
||||
reportLog('Video element removed from page', 'warn');
|
||||
lastVideoSrc = undefined;
|
||||
closeAudioContext();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'];
|
||||
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 |
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "AKTUALISIEREN",
|
||||
"BTN_REFRESH_TOOLTIP": "Die Liste der öffentlichen Räume aktualisieren",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Aktualisiere...",
|
||||
"BTN_REFRESH_COOLDOWN": "WARTE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Die Raumliste kühlt ab. Versuche es in {seconds}s erneut.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aktualisiere öffentliche Räume. Nächste Aktualisierung in {seconds}s verfügbar.",
|
||||
"LABEL_ACTIVE_ROOM": "Aktiver Raum",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Der Raum, mit dem du gerade verbunden bist",
|
||||
"ACTIVE_ROOM_NONE": "KEINER",
|
||||
@@ -88,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",
|
||||
@@ -176,5 +180,30 @@
|
||||
"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": "☕ Kauf mir einen Kaffee",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "REFRESH",
|
||||
"BTN_REFRESH_TOOLTIP": "Refresh the list of public rooms",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Refreshing...",
|
||||
"BTN_REFRESH_COOLDOWN": "WAIT {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Room list refresh is cooling down. Try again in {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Refreshing public rooms. Next refresh available in {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Active Room",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "The room you are currently connected to",
|
||||
"ACTIVE_ROOM_NONE": "NONE",
|
||||
@@ -88,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",
|
||||
@@ -176,5 +180,30 @@
|
||||
"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": "☕ Buy me a coffee",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ACTUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Actualizar la lista de salas públicas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Actualizando...",
|
||||
"BTN_REFRESH_COOLDOWN": "ESPERA {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "La lista de salas está en espera. Inténtalo de nuevo en {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualizando salas públicas. Próxima actualización en {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Sala activa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La sala a la que estás conectado actualmente",
|
||||
"ACTIVE_ROOM_NONE": "NINGUNA",
|
||||
@@ -88,6 +91,7 @@
|
||||
"BTN_REGEN_ID": "Regenerar ID de participante",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerar tu ID interno y volver a conectarte",
|
||||
"REGEN_ID_DESC": "Usa esto si ves errores de 'Identidad duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "¿Otro problema? Abre un Issue en GitHub",
|
||||
"LABEL_CONN_STATUS": "Estado de la conexión",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Estado actual de la conexión WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desconectado",
|
||||
@@ -176,5 +180,30 @@
|
||||
"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": "☕ Invítame a un café",
|
||||
"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",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Ecualizador",
|
||||
"AUDIO_COMING_SOON": "Próximamente"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ACTUALISER",
|
||||
"BTN_REFRESH_TOOLTIP": "Actualiser la liste des salons publics",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Actualisation...",
|
||||
"BTN_REFRESH_COOLDOWN": "ATTENDRE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "La liste des salons est en pause. Réessayez dans {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualisation des salons publics. Prochaine actualisation dans {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Salon actif",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Le salon auquel vous êtes actuellement connecté",
|
||||
"ACTIVE_ROOM_NONE": "AUCUN",
|
||||
@@ -88,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é",
|
||||
@@ -176,5 +180,30 @@
|
||||
"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": "☕ Offre-moi un café",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Stanza",
|
||||
"TAB_ROOM_TOOLTIP": "Impostazioni della stanza e connessione",
|
||||
"TAB_SYNC": "Sincronizza",
|
||||
"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 ospitato autonomamente",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://tuo-server:3000",
|
||||
"LABEL_ROOM_ID": "ID Stanza",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "L'identificatore univoco per la tua stanza di sincronizzazione",
|
||||
"PLACEHOLDER_ROOM_ID": "Inserisci ID Stanza",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID univoco della stanza a cui vuoi unirti",
|
||||
"LABEL_PASSWORD": "Password (Opzionale)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Password opzionale per limitare l'accesso alla stanza",
|
||||
"PLACEHOLDER_PASSWORD": "Password della stanza (opzionale)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password per la stanza (lascia vuoto se nessuna)",
|
||||
"BTN_JOIN_ROOM": "Entra / Crea Stanza",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Connettiti alla stanza",
|
||||
"LABEL_PUBLIC_ROOMS": "Stanze Pubbliche",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubblicamente disponibili su questo server",
|
||||
"BTN_REFRESH": "AGGIORNA",
|
||||
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze pubbliche",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Aggiornamento...",
|
||||
"BTN_REFRESH_COOLDOWN": "ATTENDI {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "L'aggiornamento dell'elenco è in pausa. Riprova tra {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento stanze pubbliche. Prossimo aggiornamento disponibile tra {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Stanza Attiva",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei attualmente connesso",
|
||||
"ACTIVE_ROOM_NONE": "NESSUNA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Server Ufficiale",
|
||||
"LABEL_INVITE_LINK": "Link di Invito",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Condividi questo link con gli amici per farli entrare",
|
||||
"LABEL_PEERS_IN_ROOM": "Partecipanti nella Stanza",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Altri utenti attualmente connessi a questa stanza",
|
||||
"NO_PEERS_CONNECTED": "Nessun partecipante connesso",
|
||||
"BTN_LEAVE_ROOM": "Lascia Stanza",
|
||||
"LABEL_SELECT_VIDEO": "Seleziona Video",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Scegli la scheda del browser contenente il video da sincronizzare",
|
||||
"OPTION_SELECT_TAB": "-- Seleziona una Scheda --",
|
||||
"LABEL_REMOTE_CONTROL": "Controllo Remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link di Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copia Link di Invito",
|
||||
"BTN_PLAY": "▶ Riproduci",
|
||||
"BTN_PLAY_TOOLTIP": "Invia un comando di riproduzione a tutti",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Invia un comando di pausa a tutti",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione di tutti gli utenti",
|
||||
"OPTION_JUMP_TO_OTHERS": "Salto dagli Altri",
|
||||
"OPTION_JUMP_TO_ME": "Salto da Me",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Scegli il target di sincronizzazione",
|
||||
"LABEL_LAST_ACTIVITY": "Stato Ultima Attività",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra il comando più recente di riproduzione, pausa o ricerca",
|
||||
"NO_RECENT_COMMANDS": "Nessun comando recente",
|
||||
"LOBBY_HEADER": "LOBBY EPISODIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 In attesa di: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "In attesa dei partecipanti...",
|
||||
"BTN_SKIP_PLAY": "Salta e riproduci comunque",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Annulla la lobby e riproduci comunque",
|
||||
"LOBBY_CONNECT_FIRST": "Connettiti prima a una stanza",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Devi unirti a una stanza tramite un link di invito o crearne una nuova per sincronizzare i video.",
|
||||
"BTN_CREATE_ROOM_ALT": "Crea Nuova Stanza",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crea una nuova stanza casuale ed entra",
|
||||
"LABEL_USERNAME": "Tuo Nome Utente",
|
||||
"LABEL_USERNAME_TOOLTIP": "Il nome utente aiuta gli altri a identificarti.",
|
||||
"PLACEHOLDER_USERNAME": "Koala Anonimo",
|
||||
"LABEL_HIDE_CLUTTER": "Nascondi Schede Inutili",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra le schede senza video e i domini non correlati per mantenere pulito l'elenco",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Auto-Sync Prossimo Episodio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e attende tutti i partecipanti al cambio episodio, poi si avvia in sincro.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Copia Automatica Invito",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente il link di invito negli appunti quando crei una nuova stanza.",
|
||||
"LABEL_NOTIFICATIONS": "Notifiche del Browser",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra le notifiche di sistema quando qualcuno entra/esce o riproduce/mette in pausa.",
|
||||
"LABEL_LANGUAGE": "Lingua Applicazione",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Scegli la lingua preferita per l'estensione",
|
||||
"LABEL_TROUBLESHOOTING": "Risoluzione Problemi",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Strumenti per risolvere problemi di connessione",
|
||||
"BTN_REGEN_ID": "Rigenera ID Peer",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Rigenera il tuo ID interno e riconnettiti",
|
||||
"REGEN_ID_DESC": "Usa questo se riscontri errori di \"Identità Duplicata\".",
|
||||
"REGEN_ID_OTHER_ISSUE": "Altro problema? Apri una Issue su GitHub",
|
||||
"LABEL_CONN_STATUS": "Stato Connessione",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Stato corrente della connessione WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Disconnesso",
|
||||
"BTN_RETRY": "RIPROVA",
|
||||
"BTN_RETRY_TOOLTIP": "Tenta di riconnettersi al server",
|
||||
"BTN_COPY_LOGS": "Copia Log",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copia i log negli appunti per la condivisione",
|
||||
"LABEL_VIDEO_DEBUG": "Info Debug Video",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Dettagli tecnici sull'elemento video attualmente selezionato",
|
||||
"VIDEO_DEBUG_EMPTY": "Nessuna scheda selezionata o video rilevato.",
|
||||
"LABEL_HISTORY": "Cronologia Azioni Completa",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registro cronologico di tutti i comandi di sincronizzazione nella stanza",
|
||||
"HISTORY_EMPTY": "Ancora nessuna attività",
|
||||
"LABEL_LOGS": "Log (Ultimi 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Log di connessione tecnica per il debug",
|
||||
"BTN_CLEAR": "PULISCI",
|
||||
"BTN_CLEAR_TOOLTIP": "Cancella l'output del log",
|
||||
"LABEL_GITHUB": "Repository GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Salta",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Salta il tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Avanti",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Vai al passaggio successivo",
|
||||
"ONBOARDING_1_TITLE": "Benvenuto in KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Guarda i video insieme in perfetta sincronia, non importa dove ti trovi. Facciamo un rapido tour!",
|
||||
"ONBOARDING_2_TITLE": "1. Crea una Stanza",
|
||||
"ONBOARDING_2_TEXT": "Inizia qui. Crea una stanza e condividi il link di invito con i tuoi amici.",
|
||||
"ONBOARDING_3_TITLE": "2. Seleziona Video",
|
||||
"ONBOARDING_3_TEXT": "Naviga qui per selezionare il video che vuoi sincronizzare. Riproduci, metti in pausa e cerca: tutti rimangono sincronizzati.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizza",
|
||||
"ONBOARDING_4_TEXT": "Scegli un nome utente divertente così i tuoi amici sanno chi sei.",
|
||||
"ONBOARDING_5_TITLE": "Tutto pronto!",
|
||||
"ONBOARDING_5_TEXT": "È ora di prendere i popcorn. Buona visione insieme!",
|
||||
"ERR_CONN_TIMEOUT": "Connessione scaduta. Riprova.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato URL del server non valido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento e riprova.",
|
||||
"ERR_NO_PEERS_TIME": "Nessun altro peer con un orario noto. Passa a 'Salto da Me'.",
|
||||
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
|
||||
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
|
||||
"TOAST_INVITE_COPIED": "Link di invito copiato!",
|
||||
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
|
||||
"TOAST_LOBBY_SKIPPED": "Lobby dell'episodio saltata.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Impossibile saltare la lobby.",
|
||||
"TOAST_LOGS_COPIED": "Copiato!",
|
||||
"TOAST_PEER_JOINED": "{name} è entrato nella stanza",
|
||||
"TOAST_PEER_LEFT": "{name} ha lasciato la stanza",
|
||||
"TOAST_PEER_ACTION": "{name} ha {action}",
|
||||
"STATUS_CONNECTED": "Connesso",
|
||||
"STATUS_RECONNECTING": "Riconnessione...",
|
||||
"STATUS_CONNECTING": "Connessione in corso...",
|
||||
"STATUS_FAILED": "Fallito",
|
||||
"STATUS_DISCONNECTED": "Disconnesso",
|
||||
"BTN_STATE_JOINING": "🚀 Entrando...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Riconnessione...",
|
||||
"BTN_STATE_PLAYING": "▶ Riproduzione...",
|
||||
"BTN_STATE_PAUSING": "⏸ In pausa...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione al gruppo ({time})...",
|
||||
"BTN_STATE_SYNCING": "Sincronizzazione...",
|
||||
"BTN_STATE_SYNCED": "✅ Sincronizzato!",
|
||||
"NOTIF_PLAY": "avviato la riproduzione",
|
||||
"NOTIF_PAUSE": "messo in pausa la riproduzione",
|
||||
"NOTIF_SEEK": "cercato nel video",
|
||||
"NOTIF_FORCE_PREPARE": "avviato la sincronizzazione forzata",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizzato tutti",
|
||||
"DEBUG_NO_TAB": "Nessuna scheda di destinazione selezionata.",
|
||||
"DEBUG_COMM_FAIL": "Impossibile comunicare con il video della scheda.",
|
||||
"EMPTY_PEERS_TITLE": "Ancora nessun partecipante",
|
||||
"EMPTY_PEERS_HINT": "Condividi il tuo link di invito per iniziare",
|
||||
"EMPTY_HISTORY_TITLE": "Ancora nessuna attività",
|
||||
"EMPTY_HISTORY_HINT": "Riproduci, metti in pausa o cerca per vedere la cronologia",
|
||||
"EMPTY_LOGS_TITLE": "Nessun log",
|
||||
"EMPTY_LOGS_HINT": "Gli eventi di connessione appariranno qui",
|
||||
"EMPTY_ROOMS_TITLE": "Nessuna stanza attiva",
|
||||
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna per trovare quelle pubbliche",
|
||||
"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 — Sincronizzazione Episodio Fallita",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync annullato: {reason}. Potrebbe essere necessaria una sincronizzazione manuale.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Timeout",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (ripristinato)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Tutti gli altri partecipanti hanno lasciato la stanza",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — non tutti i partecipanti hanno caricato l'episodio",
|
||||
"LOBBY_CANCEL_USER": "Annullato dall'utente",
|
||||
"NOTIF_ERROR_TITLE": "Errore KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Offrimi un caffè",
|
||||
"FOOTER_REVIEW": "★ Valuta",
|
||||
"FOOTER_SUPPORT_PROMPT": "Ti piace KoalaSync? Lascia una recensione!",
|
||||
"LABEL_AUDIO_PROCESSING": "Elaborazione audio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti audio come la compressione alla riproduzione video",
|
||||
"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": "Attivato",
|
||||
"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",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizzatore",
|
||||
"AUDIO_COMING_SOON": "Prossimamente"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"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": "☕ コーヒーをおごってね",
|
||||
"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": "近日公開"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"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": "Refreschi",
|
||||
"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": "연kel 중...",
|
||||
"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": "☕ 커피 한 잔 사주세요",
|
||||
"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": "출시 예정"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"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 cmmuniceren 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": "☕ Trakteer op een koffie",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"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": "☕ Postaw kawę",
|
||||
"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"
|
||||
}
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ATUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
|
||||
"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.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Atualizando salas públicas. Próxima atualização em {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Sala ativa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual você está conectado no momento",
|
||||
"ACTIVE_ROOM_NONE": "NENHUMA",
|
||||
@@ -88,6 +91,7 @@
|
||||
"BTN_REGEN_ID": "Regenerar ID de participante",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerar seu ID interno e conectar novamente",
|
||||
"REGEN_ID_DESC": "Use esta opção se notar erros de 'Identidade duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um Issue no GitHub",
|
||||
"LABEL_CONN_STATUS": "Status da conexão",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Status atual da conexão WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desconectado",
|
||||
@@ -176,5 +180,30 @@
|
||||
"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_USER": "Cancelado pelo usuário",
|
||||
"NOTIF_ERROR_TITLE": "Erro do KoalaSync"
|
||||
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Me pague um café",
|
||||
"FOOTER_REVIEW": "★ Avaliar",
|
||||
"FOOTER_SUPPORT_PROMPT": "Gostou do KoalaSync? Deixe uma avaliação!",
|
||||
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio como compressão à reprodução de vídeo",
|
||||
"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 de voz",
|
||||
"AUDIO_PRESET_SMOOTH": "Suave",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizado",
|
||||
"AUDIO_PARAM_THRESHOLD": "Limiar",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizador",
|
||||
"AUDIO_COMING_SOON": "Em breve"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"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": "Sincronização",
|
||||
"TAB_SYNC_TOOLTIP": "Controlo de sincronização de vídeo e ações remotas",
|
||||
"TAB_SETTINGS": "Definições",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferências da extensão",
|
||||
"TAB_STATUS": "Estado",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnóstico avanzado e registos",
|
||||
"BTN_CREATE_ROOM": "+ Criar Nova Sala",
|
||||
"MANUAL_CONNECT_HEADER": "Ligação Manual / Avançado",
|
||||
"LABEL_SERVER": "Servidor",
|
||||
"BTN_SERVER_OFFICIAL": "Oficial",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar o servidor oficial fiável",
|
||||
"BTN_SERVER_CUSTOM": "Personalizado",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Ligar ao seu próprio servidor auto-hospedado",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://o-seu-servidor:3000",
|
||||
"LABEL_ROOM_ID": "ID da Sala",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para a sua sala de sincronização",
|
||||
"PLACEHOLDER_ROOM_ID": "Introduzir ID da Sala",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala à qual deseja aderir",
|
||||
"LABEL_PASSWORD": "Palavra-passe (Opcional)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Palavra-passe opcional para restringir o acesso à sala",
|
||||
"PLACEHOLDER_PASSWORD": "Palavra-passe da sala (opcional)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Palavra-passe para la sala (deixar em branco se não houver)",
|
||||
"BTN_JOIN_ROOM": "Entrar / Criar Sala",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Ligar à 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",
|
||||
"PUBLIC_ROOMS_REFRESHING": "A atualizar...",
|
||||
"BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A atualização da lista de salas está em espera. Tente novamente em {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "A atualizar salas públicas. Próxima atualização disponível em {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Sala Ativa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual está atualmente ligado",
|
||||
"ACTIVE_ROOM_NONE": "NENHUMA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Servidor Oficial",
|
||||
"LABEL_INVITE_LINK": "Link de Invito",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Partilhar este link com os amigos para que possam entrar",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes na Sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros utilizadores attualmente ligados a questa sala",
|
||||
"NO_PEERS_CONNECTED": "Nenhum partecipante ligado",
|
||||
"BTN_LEAVE_ROOM": "Sair della Sala",
|
||||
"LABEL_SELECT_VIDEO": "Selecionar Vídeo",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolher o separador do navegador que contém o vídeo a sincronizar",
|
||||
"OPTION_SELECT_TAB": "-- Selecionar um Separador --",
|
||||
"LABEL_REMOTE_CONTROL": "Controlo Remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link de Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Invito",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Enviar um comando de Reproduzir para todos",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Enviar um comando de Pausa para todos",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forçar todos os utilizadores a sincronizarem-se",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para os Outros",
|
||||
"OPTION_JUMP_TO_ME": "Ir para Mim",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Escolher o destino de sincronização",
|
||||
"LABEL_LAST_ACTIVITY": "Estado da Última Atividade",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausa ou busca",
|
||||
"NO_RECENT_COMMANDS": "Nenhum comando recente",
|
||||
"LOBBY_HEADER": "LOBBY DE EPISÓDIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 A aguardar: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "A aguardar participantes...",
|
||||
"BTN_SKIP_PLAY": "Ignorar e reproduzir mesmo assim",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar o lobby e reproduzir mesmo assim",
|
||||
"LOBBY_CONNECT_FIRST": "Ligar-se primeiro a uma sala",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Precisa de entrar numa sala através de um link de convite ou criar uma nova para sincronizar vídeos.",
|
||||
"BTN_CREATE_ROOM_ALT": "Criar Nova Sala",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Criar uma nova sala aleatória e entrar nela",
|
||||
"LABEL_USERNAME": "O seu Nome de Utilizador",
|
||||
"LABEL_USERNAME_TOOLTIP": "O nome de utilizador ajuda os outros a identificá-lo.",
|
||||
"PLACEHOLDER_USERNAME": "Coala Anónimo",
|
||||
"LABEL_HIDE_CLUTTER": "Ocultar Separadores Desnecessários",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra separadores sem vídeo e domínios não relacionados para manter a lista limpa",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sincronização Automática do Próximo Episódio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda por todos os participantes quando um episódio muda, iniciando depois a reprodução em sincronia.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Copiar Automaticamente o Link de Convite",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link de convite para a área de transferência ao criar uma nova sala.",
|
||||
"LABEL_NOTIFICATIONS": "Notificações do Navegador",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notificações do sistema quando alguém entra/sai ou reproduz/pausa.",
|
||||
"LABEL_LANGUAGE": "Idioma da App",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Escolha o seu idioma de preferência para a extensão",
|
||||
"LABEL_TROUBLESHOOTING": "Resolução de Problemas",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para corrigir problemas de ligação",
|
||||
"BTN_REGEN_ID": "Regerar ID do Peer",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regerar o seu ID interno e voltar a ligar",
|
||||
"REGEN_ID_DESC": "Use isto se vir erros de \"Identidade Duplicata\".",
|
||||
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um Issue no GitHub",
|
||||
"LABEL_CONN_STATUS": "Estado da Ligação",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desligado",
|
||||
"BTN_RETRY": "TENTAR NOVAMENTE",
|
||||
"BTN_RETRY_TOOLTIP": "Tentar voltar a ligar ao servidor",
|
||||
"BTN_COPY_LOGS": "Copiar Registos",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copiar registos para a área de transferência para partilha",
|
||||
"LABEL_VIDEO_DEBUG": "Info de Debug de Vídeo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o elemento de vídeo atualmente seleccionado",
|
||||
"VIDEO_DEBUG_EMPTY": "Nenhum separador selecionado ou vídeo detetado.",
|
||||
"LABEL_HISTORY": "Histórico Completo de Ações",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registo cronológico de todos os comandos de sincronização na sala",
|
||||
"HISTORY_EMPTY": "Nenhuma atividade ainda",
|
||||
"LABEL_LOGS": "Registos (Últimos 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Registos de ligação técnica para depuração",
|
||||
"BTN_CLEAR": "LIMPAR",
|
||||
"BTN_CLEAR_TOOLTIP": "Limpar a saída do registo",
|
||||
"LABEL_GITHUB": "Repositório GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Saltar",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Saltar o tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Seguinte",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir para o passo seguinte",
|
||||
"ONBOARDING_1_TITLE": "Bem-vindo ao KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Veja vídeos em conjunto numa sincronizzazione perfetta — não importa onde esteja. Vamos fare uma breve visita guiada!",
|
||||
"ONBOARDING_2_TITLE": "1. Criar uma Sala",
|
||||
"ONBOARDING_2_TEXT": "Comece aqui. Crie uma sala e partilhe o link de convite com os seus amigos.",
|
||||
"ONBOARDING_3_TITLE": "2. Selecionar Vídeo",
|
||||
"ONBOARDING_3_TEXT": "Navegue até aqui para selecionar o vídeo que deseja sincronizar. Reproduza, pause e avance — todos ficam em sincronia.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizar",
|
||||
"ONBOARDING_4_TEXT": "Escolha um nome de utilizador divertido para que os seus amigos saibam quem você é.",
|
||||
"ONBOARDING_5_TITLE": "Está tudo pronto!",
|
||||
"ONBOARDING_5_TEXT": "Altura de ir buscar pipocas. Divirtam-se a ver juntos!",
|
||||
"ERR_CONN_TIMEOUT": "Ligação expirada. Tente novamente.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato de URL do Servidor inválido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento e tente novamente.",
|
||||
"ERR_NO_PEERS_TIME": "Nenhum outro peer com hora conhecida. Mude para 'Ir para Mim'.",
|
||||
"ERR_NO_VIDEO_TAB": "Não foi possível ligar ao separador do vídeo.",
|
||||
"ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!",
|
||||
"TOAST_INVITE_COPIED": "Link de convite copiado!",
|
||||
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
|
||||
"TOAST_LOBBY_SKIPPED": "Lobby do episódio ignorado.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar o lobby.",
|
||||
"TOAST_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": "⏸ A pausar...",
|
||||
"BTN_STATE_SYNCING_GROUP": "A sincronizar com o grupo ({time})....",
|
||||
"BTN_STATE_SYNCING": "A sincronizar...",
|
||||
"BTN_STATE_SYNCED": "✅ Sincronizado!",
|
||||
"NOTIF_PLAY": "iniciou a reprodução",
|
||||
"NOTIF_PAUSE": "pausou a reprodução",
|
||||
"NOTIF_SEEK": "avançou/recuou no vídeo",
|
||||
"NOTIF_FORCE_PREPARE": "iniciou a sincronização forçada",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
|
||||
"DEBUG_NO_TAB": "Nenhum separador de destino selecionado.",
|
||||
"DEBUG_COMM_FAIL": "Não foi possível comunicar com o vídeo do separador.",
|
||||
"EMPTY_PEERS_TITLE": "Nenhum participante ainda",
|
||||
"EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar",
|
||||
"EMPTY_HISTORY_TITLE": "Nenhuma atividade ainda",
|
||||
"EMPTY_HISTORY_HINT": "Reproduza, pause ou avance para ver o histórico",
|
||||
"EMPTY_LOGS_TITLE": "Nenhum registo",
|
||||
"EMPTY_LOGS_HINT": "Os eventi de ligação vão aparecer aqui",
|
||||
"EMPTY_ROOMS_TITLE": "Nenhuma sala ativa",
|
||||
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize para encontrar salas públicas",
|
||||
"LABEL_YOU": "Tu",
|
||||
"ONBOARDING_DONE": "Concluído!",
|
||||
"LABEL_LOBBY_PEER_READY": "Pronto",
|
||||
"LABEL_LOBBY_PEER_LOADING": "A carregar...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Protegido por Palavra-passe",
|
||||
"LABEL_PEERS_COUNT": "{count} participantes",
|
||||
"LABEL_CUSTOM_SERVER": "Servidor Personalizado",
|
||||
"BTN_STATE_CREATING": "🚀 A Criar Sala...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronização do Episódio Falhou",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Pode ter de sincronizar manualmente.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Timeout",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (recuperado)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — nem todos os participantes carregaram o episódio",
|
||||
"LOBBY_CANCEL_USER": "Cancelado pelo utilizador",
|
||||
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Oferece um café",
|
||||
"FOOTER_REVIEW": "★ Avaliar",
|
||||
"FOOTER_SUPPORT_PROMPT": "Gostas do KoalaSync? Deixa uma avaliação!",
|
||||
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio como compressão à reprodução de vídeo",
|
||||
"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",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizador",
|
||||
"AUDIO_COMING_SOON": "Em breve"
|
||||
}
|
||||
@@ -33,6 +33,9 @@
|
||||
"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": "НЕТ",
|
||||
@@ -88,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": "Отключено",
|
||||
@@ -176,5 +180,30 @@
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Все остальные участники вышли",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
|
||||
"LOBBY_CANCEL_USER": "Отменено пользователем",
|
||||
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync"
|
||||
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Угости кофе",
|
||||
"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": "Скоро"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"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": "☕ Bana kahve ısmarla",
|
||||
"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"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.2.0",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -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,39 @@
|
||||
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); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -375,9 +429,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;">
|
||||
@@ -495,15 +549,29 @@
|
||||
</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="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -511,6 +579,16 @@
|
||||
<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>
|
||||
<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">☕ Buy me a coffee</a>
|
||||
<span> · </span>
|
||||
<span id="settingsVersion">v0.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -519,13 +597,13 @@
|
||||
<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>
|
||||
<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>
|
||||
</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,9 +620,16 @@
|
||||
</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">☕ Buy me a coffee</a>
|
||||
<span> · </span>
|
||||
<span id="appVersion">v0.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
@@ -50,9 +50,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;
|
||||
@@ -66,41 +73,145 @@ let popupIntervals = [];
|
||||
let populateTabsToken = null;
|
||||
let errorToken = 0;
|
||||
let forceSyncDone = false;
|
||||
let connectionErrorTimer = null;
|
||||
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);
|
||||
connectionErrorTimer = null;
|
||||
}
|
||||
pendingConnectionErrorMsg = null;
|
||||
}
|
||||
|
||||
function setRoomRefreshCooldown() {
|
||||
if (roomListRefreshTimer) clearTimeout(roomListRefreshTimer);
|
||||
if (roomListRefreshInterval) clearInterval(roomListRefreshInterval);
|
||||
const originalLabel = getMessage('BTN_REFRESH');
|
||||
const updateLabel = () => {
|
||||
const secondsLeft = Math.max(1, Math.ceil((cooldownEndsAt - Date.now()) / 1000));
|
||||
elements.refreshRooms.textContent = getMessage('BTN_REFRESH_COOLDOWN', { seconds: secondsLeft });
|
||||
elements.refreshRooms.title = getMessage('BTN_REFRESH_COOLDOWN_TOOLTIP', { seconds: secondsLeft });
|
||||
};
|
||||
|
||||
const cooldownEndsAt = Date.now() + ROOM_LIST_REFRESH_COOLDOWN_MS;
|
||||
elements.refreshRooms.disabled = true;
|
||||
updateLabel();
|
||||
|
||||
roomListRefreshInterval = setInterval(updateLabel, 250);
|
||||
roomListRefreshTimer = setTimeout(() => {
|
||||
elements.refreshRooms.disabled = false;
|
||||
elements.refreshRooms.textContent = originalLabel;
|
||||
elements.refreshRooms.title = getMessage('BTN_REFRESH_TOOLTIP');
|
||||
clearInterval(roomListRefreshInterval);
|
||||
roomListRefreshInterval = null;
|
||||
roomListRefreshTimer = null;
|
||||
}, ROOM_LIST_REFRESH_COOLDOWN_MS);
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', '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 });
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
|
||||
// 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 = localData.locale;
|
||||
if (!activeLang) {
|
||||
activeLang = getSystemLanguage();
|
||||
chrome.storage.local.set({ locale: activeLang });
|
||||
}
|
||||
|
||||
await loadLocale(activeLang);
|
||||
translateDOM();
|
||||
|
||||
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}`;
|
||||
@@ -108,18 +219,24 @@ 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();
|
||||
|
||||
// Default connection status (localized) before async check
|
||||
applyConnectionStatus('disconnected');
|
||||
|
||||
// Initial Status Check
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
@@ -148,8 +265,8 @@ async function init() {
|
||||
// Check for invite link on landing page
|
||||
checkInviteLink();
|
||||
|
||||
// Initial room list fetch
|
||||
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
|
||||
// Initialize public rooms placeholder
|
||||
renderEmpty(elements.publicRooms, 'rooms');
|
||||
|
||||
// Debug Info Refresh
|
||||
popupIntervals.push(setInterval(refreshDebugInfo, 2000));
|
||||
@@ -534,7 +651,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;
|
||||
@@ -813,7 +930,7 @@ function updateRoomList(rooms) {
|
||||
function checkInviteLink() {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const tab = tabs[0];
|
||||
if (tab && tab.url && tab.url.includes(OFFICIAL_LANDING_PAGE_URL) && tab.url.includes('#join:')) {
|
||||
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/') && tab.url.includes('#join:')) {
|
||||
try {
|
||||
const rawHash = tab.url.split('#join:')[1];
|
||||
if (!rawHash) return;
|
||||
@@ -842,7 +959,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)';
|
||||
@@ -859,9 +976,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' });
|
||||
}
|
||||
@@ -873,46 +990,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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -921,7 +1076,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' });
|
||||
@@ -952,7 +1107,12 @@ function showToast(message, type = 'info', duration = 3000) {
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
toast.textContent = message || '';
|
||||
toast.style.whiteSpace = 'pre-wrap';
|
||||
|
||||
const delay = Math.max(0, duration - 600);
|
||||
toast.style.animation = `toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in ${delay}ms forwards`;
|
||||
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
@@ -981,6 +1141,7 @@ elements.roomId.addEventListener('input', () => {
|
||||
});
|
||||
|
||||
elements.joinBtn.addEventListener('click', async () => {
|
||||
clearConnectionErrorTimer();
|
||||
if (elements.joinBtn.disabled) return;
|
||||
const roomIdInput = elements.roomId.value.trim();
|
||||
const isCreating = !roomIdInput;
|
||||
@@ -1029,7 +1190,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
|
||||
@@ -1040,8 +1201,9 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
elements.leaveBtn.addEventListener('click', async () => {
|
||||
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 = [];
|
||||
@@ -1080,10 +1242,13 @@ if (syncTabCreateRoomBtn) syncTabCreateRoomBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.refreshRooms.addEventListener('click', () => {
|
||||
if (elements.refreshRooms.disabled) return;
|
||||
setRoomRefreshCooldown();
|
||||
|
||||
elements.publicRooms.replaceChildren();
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;';
|
||||
el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING');
|
||||
el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING_COOLDOWN', { seconds: Math.ceil(ROOM_LIST_REFRESH_COOLDOWN_MS / 1000) });
|
||||
elements.publicRooms.appendChild(el);
|
||||
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
|
||||
});
|
||||
@@ -1307,10 +1472,29 @@ async function refreshLogs() {
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (!msg) return;
|
||||
if (msg.type === 'LOG_UPDATE') {
|
||||
refreshLogs();
|
||||
if (msg.log && msg.log.type === 'error') {
|
||||
showError(msg.log.message);
|
||||
const errMsg = msg.log.message || '';
|
||||
const isConnErr = typeof errMsg === 'string' && (
|
||||
errMsg.toLowerCase().includes('connection') ||
|
||||
errMsg.toLowerCase().includes('websocket')
|
||||
);
|
||||
if (isConnErr) {
|
||||
pendingConnectionErrorMsg = msg.log.message;
|
||||
if (!connectionErrorTimer) {
|
||||
connectionErrorTimer = setTimeout(() => {
|
||||
if (pendingConnectionErrorMsg) {
|
||||
showError(pendingConnectionErrorMsg);
|
||||
}
|
||||
connectionErrorTimer = null;
|
||||
pendingConnectionErrorMsg = null;
|
||||
}, 5000);
|
||||
}
|
||||
} else {
|
||||
showError(msg.log.message);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'ACTION_UPDATE') {
|
||||
const state = msg.state;
|
||||
@@ -1366,6 +1550,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') {
|
||||
clearConnectionErrorTimer();
|
||||
}
|
||||
if (msg.status !== 'reconnecting') {
|
||||
applyConnectionStatus(msg.status);
|
||||
reconnectSlowMode = false;
|
||||
@@ -1397,9 +1584,16 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
} else if (msg.type === 'ROOM_LIST') {
|
||||
updateRoomList(msg.rooms);
|
||||
} else if (msg.type === 'JOIN_STATUS') {
|
||||
if (joinBtnTimeout) {
|
||||
clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = null;
|
||||
}
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
|
||||
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);
|
||||
});
|
||||
} else {
|
||||
@@ -1462,6 +1656,24 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
if (vs.platform) lines.push(`- **Platform:** ${safe(vs.platform, '?')}`);
|
||||
lines.push(`- **Video Count:** ${safe(vs.videoCount, 0)} | **Shadow DOM:** ${vs.inShadowDom ? 'YES' : 'NO'}`);
|
||||
lines.push('');
|
||||
|
||||
// Multi-video overview
|
||||
const videos = Array.isArray(vs.allVideos) ? vs.allVideos : [];
|
||||
if (videos.length > 1) {
|
||||
lines.push('### All Videos on Page');
|
||||
lines.push('');
|
||||
lines.push('| # | Resolution | Muted | Paused | Ready | Duration | Selected |');
|
||||
lines.push('|---|------------|-------|--------|-------|----------|----------|');
|
||||
const rl = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA'];
|
||||
for (const v of videos) {
|
||||
if (!v) continue;
|
||||
const sel = v.selected ? ' **\u2190 TARGET**' : '';
|
||||
const dim = `${safe(v.width, '?')}x${safe(v.height, '?')}`;
|
||||
const rs = (v.readyState != null && v.readyState >= 0 && v.readyState <= 4) ? rl[v.readyState] : '?';
|
||||
lines.push(`| ${safe(v.index, '?')} | ${dim} | ${safe(v.muted, '?')} | ${safe(v.paused, '?')} | ${rs} | ${safe(v.duration, 0)}s |${sel} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connection ──
|
||||
@@ -1536,12 +1748,12 @@ 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;
|
||||
const ts = safe(h.timestamp, '');
|
||||
const evt = safe(h.event, safe(h.type, '?'));
|
||||
const evt = safe(h.action, '?');
|
||||
const from = h.senderId ? ` (${h.senderId})` : (h.peerId ? ` (${h.peerId})` : '');
|
||||
const extra = h.detail ? ` \u2192 ${h.detail}` : '';
|
||||
lines.push(`[${ts}] ${evt}${from}${extra}`);
|
||||
@@ -1555,7 +1767,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;
|
||||
@@ -1571,6 +1783,28 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
const original = elements.copyLogs.textContent;
|
||||
elements.copyLogs.textContent = getMessage('TOAST_LOGS_COPIED');
|
||||
setTimeout(() => { elements.copyLogs.textContent = original; }, 2000);
|
||||
|
||||
// Construct rich multiline toast summary
|
||||
const verStr = safe(status.version, '?');
|
||||
const logsCount = logs.length;
|
||||
const historyCount = history.length;
|
||||
const peersCount = Array.isArray(status.peers) ? status.peers.length : 0;
|
||||
|
||||
let summary = `📋 Debug Report Copied!\n`;
|
||||
summary += `• Version: v${verStr}\n`;
|
||||
summary += `• System Logs: ${logsCount} entries\n`;
|
||||
summary += `• Sync Actions: ${historyCount} captured\n`;
|
||||
if (status.roomId) {
|
||||
summary += `• Peers in Room: ${peersCount}\n`;
|
||||
}
|
||||
if (rawVideo && vs.found) {
|
||||
const stateStr = vs.paused === true ? 'Paused' : (vs.paused === false ? 'Playing' : 'Unknown');
|
||||
const timeStr = typeof vs.currentTime === 'number' ? `${Math.round(vs.currentTime)}s` : '?';
|
||||
summary += `• Video: ${stateStr} at ${timeStr}\n`;
|
||||
}
|
||||
summary += `Paste it in markdown to view!`;
|
||||
|
||||
showToast(summary, 'success', 5000);
|
||||
}).catch(() => {
|
||||
showToast(getMessage('TOAST_COPY_FAILED'), 'error');
|
||||
});
|
||||
@@ -1735,6 +1969,14 @@ window.addEventListener('unload', () => {
|
||||
clearTimeout(forceSyncResetTimer);
|
||||
forceSyncResetTimer = null;
|
||||
}
|
||||
if (roomListRefreshTimer) {
|
||||
clearTimeout(roomListRefreshTimer);
|
||||
roomListRefreshTimer = null;
|
||||
}
|
||||
if (roomListRefreshInterval) {
|
||||
clearInterval(roomListRefreshInterval);
|
||||
roomListRefreshInterval = null;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Episode Lobby UI ---
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.1.3",
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
@@ -1240,7 +1240,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1374,7 +1373,6 @@
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -1897,7 +1895,6 @@
|
||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.2.0",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
|
||||
@@ -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');
|
||||
@@ -0,0 +1,64 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const contentPath = path.join(__dirname, '..', 'extension', 'content.js');
|
||||
const source = fs.readFileSync(contentPath, 'utf8');
|
||||
|
||||
function extractFunction(name, text) {
|
||||
const start = text.indexOf(`function ${name}`);
|
||||
assert.notStrictEqual(start, -1, `${name} not found`);
|
||||
|
||||
const bodyStart = text.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = bodyStart; i < text.length; i++) {
|
||||
if (text[i] === '{') depth++;
|
||||
if (text[i] === '}') depth--;
|
||||
if (depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`${name} body did not terminate`);
|
||||
}
|
||||
|
||||
function makeVideo(name, width, height, options = {}) {
|
||||
return {
|
||||
name,
|
||||
tagName: 'VIDEO',
|
||||
videoWidth: width,
|
||||
videoHeight: height,
|
||||
offsetWidth: width,
|
||||
offsetHeight: height,
|
||||
muted: options.muted ?? true,
|
||||
duration: options.duration ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const lightPreview = makeVideo('light-preview', 160, 90, { muted: false, duration: 30 });
|
||||
const shadowPlayer = makeVideo('shadow-player', 1920, 1080, { muted: false, duration: 3600 });
|
||||
|
||||
const shadowRoot = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [shadowPlayer];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const shadowHost = { shadowRoot };
|
||||
|
||||
const fakeDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [lightPreview];
|
||||
return [shadowHost];
|
||||
}
|
||||
};
|
||||
|
||||
const fnSource = extractFunction('findVideo', source);
|
||||
const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument);
|
||||
|
||||
const selected = findVideo(fakeDocument);
|
||||
assert.strictEqual(
|
||||
selected,
|
||||
shadowPlayer,
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
console.log('content video finder tests passed');
|
||||
@@ -9,19 +9,72 @@ if (!fs.existsSync(enPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
// Verify SUPPORTED_LANGUAGES in extension/i18n.js matches JSON files
|
||||
const i18nPath = path.join(__dirname, '..', 'extension', 'i18n.js');
|
||||
try {
|
||||
if (fs.existsSync(i18nPath)) {
|
||||
const i18nContent = fs.readFileSync(i18nPath, 'utf8');
|
||||
const langMatch = i18nContent.match(/export const SUPPORTED_LANGUAGES = \[(.*?)\];/);
|
||||
if (!langMatch) {
|
||||
hasError = true;
|
||||
console.error('❌ Could not parse SUPPORTED_LANGUAGES from extension/i18n.js');
|
||||
} else {
|
||||
const supportedLangs = langMatch[1].split(',').map(s => s.trim().replace(/['"]/g, ''));
|
||||
const fileLangs = fs.readdirSync(localesDir)
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.map(file => file.replace('.json', ''));
|
||||
|
||||
for (const lang of fileLangs) {
|
||||
if (!supportedLangs.includes(lang)) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${lang}.json exists in extension/locales but is missing from SUPPORTED_LANGUAGES in extension/i18n.js`);
|
||||
}
|
||||
}
|
||||
for (const lang of supportedLangs) {
|
||||
if (!fileLangs.includes(lang)) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${lang} is in SUPPORTED_LANGUAGES in extension/i18n.js but ${lang}.json is missing from extension/locales`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
hasError = true;
|
||||
console.error('❌ Failed to verify SUPPORTED_LANGUAGES synchronization:', err.message);
|
||||
}
|
||||
|
||||
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
const enKeys = Object.keys(enDict);
|
||||
|
||||
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json') && file !== 'en.json');
|
||||
|
||||
let hasError = false;
|
||||
|
||||
console.log(`Auditing i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const filePath = path.join(localesDir, file);
|
||||
try {
|
||||
const dict = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Check for duplicate keys in raw JSON before parsing
|
||||
const keyRe = /"(\w+)"\s*:/g;
|
||||
const seenKeys = {};
|
||||
let dupes = [];
|
||||
let m;
|
||||
while ((m = keyRe.exec(raw)) !== null) {
|
||||
if (seenKeys[m[1]]) {
|
||||
dupes.push(m[1]);
|
||||
}
|
||||
seenKeys[m[1]] = true;
|
||||
}
|
||||
if (dupes.length > 0) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${file} has duplicate keys: ${[...new Set(dupes)].join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dict = JSON.parse(raw);
|
||||
const keys = Object.keys(dict);
|
||||
|
||||
const missingKeys = enKeys.filter(k => !keys.includes(k));
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cwd } from 'node:process';
|
||||
|
||||
const popupPath = path.join(cwd(), 'extension', 'popup.js');
|
||||
const source = fs.readFileSync(popupPath, 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const ROOM_LIST_REFRESH_COOLDOWN_MS\s*=\s*11000;/,
|
||||
'popup should define an 11 second room-list refresh cooldown'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/elements\.refreshRooms\.disabled\s*=\s*true;/,
|
||||
'refresh button should be disabled while cooldown is active'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/setTimeout\(\(\)\s*=>\s*{\s*elements\.refreshRooms\.disabled\s*=\s*false;/s,
|
||||
'refresh button should be re-enabled after the cooldown'
|
||||
);
|
||||
|
||||
console.log('popup refresh cooldown tests passed');
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from '../server/ops.js';
|
||||
|
||||
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
|
||||
assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics');
|
||||
|
||||
const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token');
|
||||
assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics');
|
||||
|
||||
const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token');
|
||||
assert.equal(correctAuth, true, 'correct bearer token should authorize metrics');
|
||||
|
||||
const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', '');
|
||||
assert.equal(disabledAuth, false, 'empty admin token disables admin metrics');
|
||||
|
||||
assert.equal(isAdminMetricsTokenStrong(''), true, 'empty admin token is allowed because metrics stay disabled');
|
||||
assert.equal(isAdminMetricsTokenStrong('short-token'), false, 'short admin token should be reported as weak');
|
||||
assert.equal(
|
||||
isAdminMetricsTokenStrong('a'.repeat(32)),
|
||||
true,
|
||||
'admin token with at least 32 characters should be considered strong'
|
||||
);
|
||||
|
||||
const cooldowns = new Map();
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes');
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
|
||||
|
||||
const cache = new Map();
|
||||
let buildCalls = 0;
|
||||
const firstCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 1_000);
|
||||
const secondCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 30_000);
|
||||
const expiredCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 61_001);
|
||||
assert.deepEqual(firstCached, { value: 1 }, 'cache should return the builder payload on first request');
|
||||
assert.strictEqual(secondCached, firstCached, 'cache should reuse payloads inside the ttl');
|
||||
assert.deepEqual(expiredCached, { value: 2 }, 'cache should rebuild payloads after ttl expiry');
|
||||
|
||||
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
|
||||
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
|
||||
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
|
||||
|
||||
const basicHealth = buildHealthPayload({
|
||||
rooms,
|
||||
connections: 5,
|
||||
includeMetrics: false,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(basicHealth).sort(),
|
||||
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(),
|
||||
'basic health should not expose extended metrics'
|
||||
);
|
||||
|
||||
const adminHealth = buildHealthPayload({
|
||||
rooms,
|
||||
connections: 5,
|
||||
includeMetrics: true,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
});
|
||||
|
||||
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
|
||||
assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies');
|
||||
assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size');
|
||||
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
|
||||
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
|
||||
assert.deepEqual(
|
||||
adminHealth.rateLimitEntries,
|
||||
{ connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
|
||||
'admin metrics should expose aggregate rate-limit map sizes'
|
||||
);
|
||||
|
||||
console.log('server ops tests passed');
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
|
||||
HEALTH_RATE_LIMIT_PER_MINUTE,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
healthResponseCache,
|
||||
httpServer,
|
||||
rooms,
|
||||
startServer,
|
||||
stopServerForTests
|
||||
} from '../server/index.js';
|
||||
|
||||
const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
|
||||
const baseHeaders = { 'x-forwarded-for': '203.0.113.10' };
|
||||
|
||||
function url(path) {
|
||||
const address = httpServer.address();
|
||||
return `http://127.0.0.1:${address.port}${path}`;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
return fetch(url(path), {
|
||||
...options,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await startServer(0, '127.0.0.1');
|
||||
|
||||
let res = await request('/');
|
||||
assert.equal(res.status, 200, 'root health endpoint should respond');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store', 'root response should disable HTTP caching');
|
||||
assert.deepEqual(await res.json(), { status: 'online', service: 'KoalaSync Relay' });
|
||||
|
||||
res = await request('/health');
|
||||
assert.equal(res.status, 200, 'basic health endpoint should respond');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store', 'basic health response should disable HTTP caching');
|
||||
const basicHealth = await res.json();
|
||||
assert.equal(basicHealth.status, 'ok', 'basic health should report ok');
|
||||
assert.equal(basicHealth.rooms, 0, 'basic health should include room count');
|
||||
assert.equal('peers' in basicHealth, false, 'basic health should not expose admin metrics');
|
||||
assert.equal('memory' in basicHealth, false, 'basic health should not expose memory metrics');
|
||||
|
||||
rooms.set('route-test-room', {
|
||||
peers: new Set(['socket-a', 'socket-b']),
|
||||
peerData: new Map(),
|
||||
peerIds: new Map(),
|
||||
activeLobby: { expectedTitle: 'Episode 1', initiatorPeerId: 'peer-a', readyPeers: ['peer-a'] }
|
||||
});
|
||||
healthResponseCache.clear();
|
||||
healthCounts.clear();
|
||||
|
||||
res = await request('/health', {
|
||||
headers: { authorization: `Bearer ${adminToken}`, 'x-forwarded-for': '203.0.113.20' }
|
||||
});
|
||||
assert.equal(res.status, 200, 'authorized admin health endpoint should respond');
|
||||
const adminHealth = await res.json();
|
||||
assert.equal(adminHealth.rooms, 1, 'admin health should include room count');
|
||||
assert.equal(adminHealth.peers, 2, 'admin health should include aggregate peer count');
|
||||
assert.equal(adminHealth.roomsWithLobby, 1, 'admin health should include aggregate lobby count');
|
||||
assert.equal(typeof adminHealth.memory?.rss, 'number', 'admin health should include aggregate memory metrics');
|
||||
assert.equal('route-test-room' in adminHealth, false, 'admin health should not expose room identifiers');
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
for (let i = 0; i < ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE; i++) {
|
||||
res = await request('/health', {
|
||||
headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' }
|
||||
});
|
||||
assert.equal(res.status, 200, `wrong admin bearer attempt ${i + 1} should still return basic health`);
|
||||
}
|
||||
res = await request('/health', {
|
||||
headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' }
|
||||
});
|
||||
assert.equal(res.status, 429, 'wrong admin bearer attempts should be throttled after the limit');
|
||||
|
||||
healthCounts.clear();
|
||||
for (let i = 0; i < HEALTH_RATE_LIMIT_PER_MINUTE; i++) {
|
||||
const path = i % 2 === 0 ? '/' : '/health';
|
||||
res = await request(path, { headers: { 'x-forwarded-for': '203.0.113.40' } });
|
||||
assert.equal(res.status, 200, `shared health request ${i + 1} should be allowed`);
|
||||
}
|
||||
res = await request('/', { headers: { 'x-forwarded-for': '203.0.113.40' } });
|
||||
assert.equal(res.status, 429, 'root and health should share the public health rate limit');
|
||||
|
||||
console.log('server route tests passed');
|
||||
} finally {
|
||||
await stopServerForTests();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const localesDir = path.join(__dirname, '..', 'website', 'locales');
|
||||
const enPath = path.join(localesDir, 'en.json');
|
||||
|
||||
if (!fs.existsSync(enPath)) {
|
||||
console.error('CRITICAL: website/locales/en.json is missing!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
const enKeys = Object.keys(enDict).sort();
|
||||
|
||||
const localeFiles = fs.readdirSync(localesDir)
|
||||
.filter(file => file.endsWith('.json') && file !== 'en.json')
|
||||
.sort();
|
||||
|
||||
let hasError = false;
|
||||
|
||||
console.log(`Auditing website i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const filePath = path.join(localesDir, file);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Check for duplicate keys in raw JSON before parsing
|
||||
const keyRe = /"(\w+)"\s*:/g;
|
||||
const seenKeys = {};
|
||||
let dupes = [];
|
||||
let m;
|
||||
while ((m = keyRe.exec(raw)) !== null) {
|
||||
if (seenKeys[m[1]]) {
|
||||
dupes.push(m[1]);
|
||||
}
|
||||
seenKeys[m[1]] = true;
|
||||
}
|
||||
if (dupes.length > 0) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${file} has duplicate keys: ${[...new Set(dupes)].join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dict = JSON.parse(raw);
|
||||
const keys = Object.keys(dict).sort();
|
||||
|
||||
const missingKeys = enKeys.filter(k => !keys.includes(k));
|
||||
const extraKeys = keys.filter(k => !enKeys.includes(k));
|
||||
|
||||
if (missingKeys.length > 0 || extraKeys.length > 0) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${file} has inconsistencies:`);
|
||||
if (missingKeys.length > 0) {
|
||||
console.error(` Missing keys (${missingKeys.length}):`, missingKeys);
|
||||
}
|
||||
if (extraKeys.length > 0) {
|
||||
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
|
||||
}
|
||||
} else {
|
||||
console.log(`✓ ${file} is fully consistent (matches all keys).`);
|
||||
}
|
||||
} catch (err) {
|
||||
hasError = true;
|
||||
console.error(`❌ Failed to parse ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (hasError) {
|
||||
console.error('❌ Website locale consistency check failed! Fix the errors above.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('🎉 All website locale files are perfectly synchronized and consistent!');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const checks = [
|
||||
['server ops', 'node', ['scripts/test-server-ops.mjs']],
|
||||
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
|
||||
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
|
||||
}],
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.js']],
|
||||
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
|
||||
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
|
||||
['server syntax index', 'node', ['-c', 'server/index.js']],
|
||||
['server syntax ops', 'node', ['-c', 'server/ops.js']],
|
||||
['content syntax', 'node', ['-c', 'extension/content.js']],
|
||||
['popup syntax', 'node', ['-c', 'extension/popup.js']],
|
||||
['background syntax', 'node', ['-c', 'extension/background.js']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.js']],
|
||||
['website locale coverage', 'node', ['scripts/test-website-locales.mjs']],
|
||||
['lint', 'npm', ['run', 'lint']],
|
||||
['root production audit', 'npm', ['audit', '--omit=dev']],
|
||||
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
|
||||
['extension build', 'npm', ['run', 'build:extension']],
|
||||
['website build', 'node', ['website/build.js']]
|
||||
];
|
||||
|
||||
function runCheck([label, command, args, options = {}]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`\n==> ${label}`);
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
env: { ...process.env, ...(options.env || {}) },
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${label} failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const check of checks) {
|
||||
await runCheck(check);
|
||||
}
|
||||
|
||||
console.log('\nRelease verification passed');
|
||||
@@ -1,4 +1,10 @@
|
||||
PORT=3000
|
||||
MIN_VERSION=1.0.0
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=50
|
||||
PORT="3000"
|
||||
MIN_VERSION="1.0.0"
|
||||
MAX_ROOMS="1000"
|
||||
MAX_PEERS_PER_ROOM="25"
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
# Use a long random token, 32+ characters recommended.
|
||||
ADMIN_METRICS_TOKEN=""
|
||||
|
||||
# Optional: set to "1" to enable verbose connection, room-join/leave, and CORS logs in the console. Default is "0" (disabled).
|
||||
DEBUG_LOGGING="0"
|
||||
|
||||
@@ -12,12 +12,39 @@ A Node.js relay server for synchronized video playback.
|
||||
### Environment
|
||||
Copy `.env.example` to `.env` and configure your settings.
|
||||
```bash
|
||||
PORT=3000
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=50
|
||||
MIN_VERSION=1.0.0
|
||||
PORT="3000"
|
||||
MAX_ROOMS="1000"
|
||||
MAX_PEERS_PER_ROOM="25"
|
||||
MIN_VERSION="1.0.0"
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
# Use a long random token, 32+ characters recommended.
|
||||
ADMIN_METRICS_TOKEN=""
|
||||
|
||||
# Optional: set to "1" to enable verbose connection, room-join/leave, and CORS logs in the console. Default is "0" (disabled).
|
||||
DEBUG_LOGGING="0"
|
||||
```
|
||||
|
||||
### Health & Metrics
|
||||
`GET /` and `GET /health` are IP-rate-limited to 10 requests per minute per client IP. These health-style responses are cached server-side for 60 seconds and refreshed lazily on request. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
|
||||
|
||||
If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer <token>` receive additional aggregate metrics such as total peers, average peers per room, max room size, active lobby count, rate-limit map sizes, and process memory usage. Wrong admin bearer attempts are separately limited to 5 requests per minute per client IP. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
|
||||
|
||||
Use a long random `ADMIN_METRICS_TOKEN` of at least 32 characters. Shorter configured tokens still work, but the server logs a startup warning.
|
||||
|
||||
Generate a token with one of these commands:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
# or
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
```
|
||||
|
||||
For Docker Compose deployments, set the generated value in `server/.env`:
|
||||
```bash
|
||||
ADMIN_METRICS_TOKEN=replace-with-a-long-random-token
|
||||
```
|
||||
|
||||
When polling `/health` from Prometheus, Uptime Kuma, cron, or a load balancer, keep the interval comfortably below the public limit of 10 requests per minute per client IP. A 30-60 second interval is recommended for routine monitoring. Use the admin bearer token only from trusted monitoring hosts, and keep the Node server private behind Caddy or another trusted reverse proxy because IP-based limits depend on the configured proxy boundary.
|
||||
|
||||
### Docker (Recommended)
|
||||
The server is available as a pre-built image on GHCR.
|
||||
```bash
|
||||
@@ -27,7 +54,7 @@ docker pull ghcr.io/shik3i/koalasync:latest
|
||||
# Or build from the repository root
|
||||
docker build -t koala-sync-server -f server/Dockerfile .
|
||||
```
|
||||
See [Docker network compose](../docker-compose.caddy.example.yml) or [Static IP compose](../docker-compose.ip.example.yml) in the root directory for ready-to-use Docker Compose files.
|
||||
See [Docker network compose](../examples/docker-compose.caddy.example.yml) or [Static IP compose](../examples/docker-compose.ip.example.yml) in the root directory for ready-to-use Docker Compose files.
|
||||
|
||||
### Manual Setup
|
||||
```bash
|
||||
@@ -38,6 +65,9 @@ npm start
|
||||
|
||||
## Security
|
||||
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
||||
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 10 requests per minute per IP, with 60-second lazy server-side response caching and stricter throttling for wrong admin bearer attempts.
|
||||
- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket.
|
||||
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
|
||||
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
|
||||
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
|
||||
- **Reverse Proxy Boundary**: The server trusts one reverse proxy hop for client IP detection. Keep the Node port private/firewalled so clients can only reach it through Caddy or another trusted proxy.
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from './ops.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -15,19 +23,36 @@ function hashPassword(password) {
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
|
||||
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 50;
|
||||
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25;
|
||||
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
|
||||
const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || '';
|
||||
const ROOM_LIST_COOLDOWN_MS = 10000;
|
||||
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
|
||||
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
|
||||
const HEALTH_RESPONSE_CACHE_TTL_MS = 60000;
|
||||
|
||||
const app = express();
|
||||
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
|
||||
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
|
||||
}
|
||||
|
||||
export const app = express();
|
||||
app.set('trust proxy', 1); // For real client IP through reverse proxy
|
||||
|
||||
export const healthResponseCache = new Map();
|
||||
|
||||
// Health Check with Rate Limiting
|
||||
app.get('/', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Too many requests. Try again later.' });
|
||||
}
|
||||
res.json({ status: 'online', service: 'KoalaSync Relay' });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(getCachedPayload(
|
||||
healthResponseCache,
|
||||
'root',
|
||||
HEALTH_RESPONSE_CACHE_TTL_MS,
|
||||
() => ({ status: 'online', service: 'KoalaSync Relay' })
|
||||
));
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
@@ -35,19 +60,37 @@ app.get('/health', (req, res) => {
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Rate limited' });
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
rooms: rooms.size,
|
||||
connections: io.engine?.clientsCount ?? 0,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
const authHeader = req.get('authorization');
|
||||
const includeMetrics = isAdminMetricsAuthorized(authHeader, ADMIN_METRICS_TOKEN);
|
||||
if (ADMIN_METRICS_TOKEN && authHeader && !includeMetrics && !checkAdminMetricsAuthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Rate limited' });
|
||||
}
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(getCachedPayload(
|
||||
healthResponseCache,
|
||||
includeMetrics ? 'health-admin' : 'health-basic',
|
||||
HEALTH_RESPONSE_CACHE_TTL_MS,
|
||||
() => buildHealthPayload({
|
||||
rooms,
|
||||
connections: io.engine?.clientsCount ?? 0,
|
||||
includeMetrics,
|
||||
uptime: process.uptime(),
|
||||
rateLimitSizes: {
|
||||
connections: connectionCounts.size,
|
||||
events: eventCounts.size,
|
||||
health: healthCounts.size,
|
||||
adminMetricsAuth: adminMetricsAuthCounts.size,
|
||||
authFailures: failedAuthAttempts.size,
|
||||
roomList: roomListCooldowns.size
|
||||
}
|
||||
})
|
||||
));
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
export const httpServer = createServer(app);
|
||||
|
||||
// Socket.IO setup with security constraints
|
||||
const io = new Server(httpServer, {
|
||||
export const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
|
||||
@@ -67,18 +110,22 @@ const io = new Server(httpServer, {
|
||||
/**
|
||||
* In-memory storage
|
||||
*/
|
||||
const rooms = new Map();
|
||||
export const rooms = new Map();
|
||||
const socketToRoom = new Map();
|
||||
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
||||
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
|
||||
|
||||
function log(type, message, details = '') {
|
||||
const debugLogging = process.env.DEBUG_LOGGING === '1';
|
||||
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS';
|
||||
if (!debugLogging && isVerbose) return;
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] [${type}] ${message}`, details);
|
||||
}
|
||||
|
||||
// Rate Limiting & Security
|
||||
const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
|
||||
function checkAuthRate(ip, roomId) {
|
||||
@@ -106,17 +153,19 @@ function recordAuthFailure(ip, roomId) {
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
} else {
|
||||
break; // Since entries are insertion-ordered by time, all subsequent entries are newer
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries
|
||||
// 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries (first items in Map order)
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing LRU-style eviction.');
|
||||
const sortedEntries = Array.from(failedAuthAttempts.entries())
|
||||
.sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
|
||||
|
||||
for (let i = 0; i < 10000 && i < sortedEntries.length; i++) {
|
||||
failedAuthAttempts.delete(sortedEntries[i][0]);
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing insertion-order eviction.');
|
||||
for (const [key] of failedAuthAttempts.entries()) {
|
||||
if (failedAuthAttempts.size <= 40000) {
|
||||
break;
|
||||
}
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,11 +173,12 @@ function recordAuthFailure(ip, roomId) {
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
record.count++;
|
||||
record.lastAttempt = Date.now();
|
||||
failedAuthAttempts.delete(key); // Remove first to update insertion order (moves key to the end of iteration)
|
||||
failedAuthAttempts.set(key, record);
|
||||
}
|
||||
|
||||
// Periodically clean up old auth failure records (every 15 minutes)
|
||||
setInterval(() => {
|
||||
const authFailureCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
@@ -137,11 +187,13 @@ setInterval(() => {
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
|
||||
// Clean up connection counts and event counts to prevent memory leak
|
||||
setInterval(() => {
|
||||
const rateLimitCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
@@ -158,6 +210,16 @@ setInterval(() => {
|
||||
healthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of adminMetricsAuthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
adminMetricsAuthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [socketId] of roomListCooldowns.entries()) {
|
||||
if (!io.sockets.sockets.has(socketId)) {
|
||||
roomListCooldowns.delete(socketId);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function checkConnectionRate(ip) {
|
||||
@@ -184,7 +246,16 @@ function checkHealthRate(ip) {
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
return entry.count <= 60;
|
||||
return entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE;
|
||||
}
|
||||
|
||||
function checkAdminMetricsAuthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
adminMetricsAuthCounts.set(ip, entry);
|
||||
return entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -503,7 +574,7 @@ io.on('connection', (socket) => {
|
||||
mediaTitle: clamp(data.mediaTitle, 100),
|
||||
volume: clampNum(data.volume, 0, 1),
|
||||
muted: validBool(data.muted),
|
||||
peerId: typeof data.peerId === 'string' ? data.peerId.substring(0, 16) : undefined,
|
||||
peerId: mapping.peerId,
|
||||
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
|
||||
expectedTitle: clamp(data.expectedTitle, 100),
|
||||
title: clamp(data.title, 100),
|
||||
@@ -541,6 +612,10 @@ io.on('connection', (socket) => {
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
|
||||
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
|
||||
return;
|
||||
}
|
||||
const list = Array.from(rooms.entries()).map(([id, r]) => ({
|
||||
id,
|
||||
peerCount: r.peers.size,
|
||||
@@ -584,6 +659,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
roomListCooldowns.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
// Socket is already disconnected — no need to call socket.leave().
|
||||
@@ -595,7 +671,7 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
// Active Room & Dead Peer Cleanup (Every 2m)
|
||||
setInterval(() => {
|
||||
const roomCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
||||
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
||||
@@ -632,9 +708,29 @@ setInterval(() => {
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
|
||||
});
|
||||
export function startServer(port = PORT, host) {
|
||||
if (httpServer.listening) return Promise.resolve(httpServer);
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (err) => {
|
||||
httpServer.off('listening', onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
httpServer.off('error', onError);
|
||||
const address = httpServer.address();
|
||||
const actualPort = address && typeof address === 'object' ? address.port : port;
|
||||
log('SERVER', `KoalaSync Relay running on port ${actualPort}`);
|
||||
resolve(httpServer);
|
||||
};
|
||||
httpServer.once('error', onError);
|
||||
httpServer.once('listening', onListening);
|
||||
if (host) {
|
||||
httpServer.listen(port, host);
|
||||
} else {
|
||||
httpServer.listen(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- M-4: Graceful Shutdown ---
|
||||
function gracefulShutdown(signal) {
|
||||
@@ -653,15 +749,44 @@ function gracefulShutdown(signal) {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
export async function stopServerForTests() {
|
||||
clearInterval(authFailureCleanupInterval);
|
||||
clearInterval(rateLimitCleanupInterval);
|
||||
clearInterval(roomCleanupInterval);
|
||||
rooms.clear();
|
||||
socketToRoom.clear();
|
||||
peerToSocket.clear();
|
||||
roomCreationLocks.clear();
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
io.disconnectSockets(true);
|
||||
if (!httpServer.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
httpServer.close((err) => err ? reject(err) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
if (isMainModule) {
|
||||
startServer(PORT);
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function checkCooldown(cooldowns, key, windowMs, now = Date.now()) {
|
||||
const lastAllowedAt = cooldowns.get(key) || 0;
|
||||
if (now - lastAllowedAt < windowMs) {
|
||||
return false;
|
||||
}
|
||||
cooldowns.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getCachedPayload(cache, key, ttlMs, buildPayload, now = Date.now()) {
|
||||
const cached = cache.get(key);
|
||||
if (cached && now - cached.createdAt < ttlMs) {
|
||||
return cached.payload;
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
cache.set(key, { createdAt: now, payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function isAdminMetricsAuthorized(authHeader, adminToken) {
|
||||
if (!adminToken || typeof adminToken !== 'string') return false;
|
||||
if (!authHeader || typeof authHeader !== 'string') return false;
|
||||
|
||||
const prefix = 'Bearer ';
|
||||
if (!authHeader.startsWith(prefix)) return false;
|
||||
|
||||
const provided = authHeader.slice(prefix.length);
|
||||
if (!provided) return false;
|
||||
|
||||
const expectedBuffer = Buffer.from(adminToken);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
export function isAdminMetricsTokenStrong(adminToken, minLength = 32) {
|
||||
return !adminToken || (typeof adminToken === 'string' && adminToken.length >= minLength);
|
||||
}
|
||||
|
||||
export function buildHealthPayload({
|
||||
rooms,
|
||||
connections,
|
||||
includeMetrics = false,
|
||||
now = Date.now(),
|
||||
uptime = 0,
|
||||
memoryUsage = () => process.memoryUsage(),
|
||||
rateLimitSizes = {}
|
||||
}) {
|
||||
const payload = {
|
||||
status: 'ok',
|
||||
uptime,
|
||||
rooms: rooms.size,
|
||||
connections,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
if (!includeMetrics) return payload;
|
||||
|
||||
const roomValues = Array.from(rooms.values());
|
||||
const roomSizes = roomValues.map(room => room.peers?.size || 0);
|
||||
const peers = roomSizes.reduce((sum, size) => sum + size, 0);
|
||||
const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0;
|
||||
const avgPeersPerRoom = roomSizes.length > 0
|
||||
? Math.round((peers / roomSizes.length) * 100) / 100
|
||||
: 0;
|
||||
const mem = memoryUsage();
|
||||
|
||||
return {
|
||||
...payload,
|
||||
peers,
|
||||
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
|
||||
avgPeersPerRoom,
|
||||
maxPeersInRoom,
|
||||
rateLimitEntries: {
|
||||
connections: rateLimitSizes.connections || 0,
|
||||
events: rateLimitSizes.events || 0,
|
||||
health: rateLimitSizes.health || 0,
|
||||
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
|
||||
authFailures: rateLimitSizes.authFailures || 0,
|
||||
roomList: rateLimitSizes.roomList || 0
|
||||
},
|
||||
memory: {
|
||||
rss: mem.rss,
|
||||
heapUsed: mem.heapUsed,
|
||||
heapTotal: mem.heapTotal
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3"
|
||||
@@ -70,15 +69,6 @@
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -266,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
|
||||
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
|
||||
"version": "6.6.8",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
|
||||
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
@@ -280,7 +270,7 @@
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
"ws": "~8.20.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
@@ -749,9 +739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -951,13 +941,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz",
|
||||
"integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
"ws": "~8.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
@@ -1079,9 +1069,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3"
|
||||
|
||||
@@ -67,7 +67,6 @@ export const BLACKLIST_DOMAINS = [
|
||||
|
||||
// Productivity & Project Management
|
||||
'atlassian.net',
|
||||
'jira',
|
||||
'trello.com',
|
||||
'notion.so',
|
||||
'monday.com',
|
||||
@@ -168,7 +167,7 @@ export const BLACKLIST_DOMAINS = [
|
||||
// Games & Idle Sites
|
||||
'milkywayidle.com',
|
||||
'melvoridle.com',
|
||||
'cookieclicker.',
|
||||
'orteil.dashnet.org',
|
||||
'clickerheroes.com',
|
||||
'kongregate.com',
|
||||
'armorgames.com',
|
||||
|
||||
@@ -12,6 +12,20 @@ export const APP_VERSION = "1.9.0";
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
export const OFFICIAL_SERVER_TOKEN = '62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50';
|
||||
export const SUPPORT_URL = 'https://support.koalastuff.net';
|
||||
export const KOFI_URL = 'https://ko-fi.com/koaladev';
|
||||
export const GITHUB_URL = 'https://github.com/Shik3i/KoalaSync';
|
||||
|
||||
export function isFirefox() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
return !!manifest.browser_specific_settings?.gecko?.id;
|
||||
}
|
||||
|
||||
export function getReviewUrl() {
|
||||
return isFirefox()
|
||||
? 'https://addons.mozilla.org/firefox/addon/koalasync/reviews/'
|
||||
: 'https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc/reviews';
|
||||
}
|
||||
|
||||
export const EVENTS = {
|
||||
// Connection & Room
|
||||
|
||||
@@ -28,7 +28,7 @@ Caddy is the recommended web server. It provides automatic HTTPS and high-perfor
|
||||
|
||||
### Recommended Caddyfile
|
||||
|
||||
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../Caddyfile.example).
|
||||
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../examples/Caddyfile.example).
|
||||
|
||||
```caddy
|
||||
sync.koalastuff.net {
|
||||
|
||||
@@ -24,12 +24,19 @@ We divide supported languages into two tiers: **Core Languages** (fully hand-cra
|
||||
|
||||
| Language Code | Language Name | Verification Status | Rationale / Context |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `en` | **English** | `100% Manually Verified` | Primary developer language and system default |
|
||||
| `de` | **German** | `100% Manually Verified` | Core market and compliance baseline |
|
||||
| `fr` | **French** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `es` | **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt-BR` | **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ru` | **Russian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `en` | 🇬🇧 **English** | `100% Manually Verified` | Primary developer language and system default |
|
||||
| `de` | 🇩🇪 **German** | `100% Manually Verified` | Core market and compliance baseline |
|
||||
| `fr` | 🇫🇷 **French** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `es` | 🇪🇸 **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt-BR` | 🇧🇷 **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ru` | 🇷🇺 **Russian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `it` | 🇮🇹 **Italian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pl` | 🇵🇱 **Polish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `tr` | 🇹🇷 **Turkish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `nl` | 🇳🇱 **Dutch** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ja` | 🇯🇵 **Japanese** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ko` | 🇰🇷 **Korean** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt` | 🇵🇹 **European Portuguese** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
|
||||
> [!WARNING]
|
||||
> **Autogeneration Quality Rule**
|
||||
@@ -155,12 +162,19 @@ In **v2.0**, we extended full internationalization support to the **Browser Exte
|
||||
|
||||
* **Locales Directory:** [`extension/locales/`](file:///Users/koala/Documents/KoalaPlay/extension/locales/)
|
||||
* **Active Dictionaries:**
|
||||
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (Baseline English)
|
||||
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (German)
|
||||
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (French)
|
||||
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (Spanish)
|
||||
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (Portuguese (Brasil))
|
||||
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (Russian)
|
||||
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (🇬🇧 Baseline English)
|
||||
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (🇩🇪 German)
|
||||
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (🇫🇷 French)
|
||||
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (🇪🇸 Spanish)
|
||||
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (🇧🇷 Portuguese (Brasil))
|
||||
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (🇷🇺 Russian)
|
||||
* [`it.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/it.json) (🇮🇹 Italian)
|
||||
* [`pl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pl.json) (🇵🇱 Polish)
|
||||
* [`tr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/tr.json) (🇹🇷 Turkish)
|
||||
* [`nl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/nl.json) (🇳🇱 Dutch)
|
||||
* [`ja.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ja.json) (🇯🇵 Japanese)
|
||||
* [`ko.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ko.json) (🇰🇷 Korean)
|
||||
* [`pt.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt.json) (🇵🇹 European Portuguese)
|
||||
* **Translation Engine:** [`extension/i18n.js`](file:///Users/koala/Documents/KoalaPlay/extension/i18n.js)
|
||||
* **Validation Script:** [`scripts/test-locales.js`](file:///Users/koala/Documents/KoalaPlay/scripts/test-locales.js)
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
// KoalaSync Landing Page Logic
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const safeGetLocalStorage = (key) => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeSetLocalStorage = (key, val) => {
|
||||
try {
|
||||
localStorage.setItem(key, val);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll Reveal Logic (IntersectionObserver for performance)
|
||||
const revealElements = document.querySelectorAll('[data-reveal]');
|
||||
|
||||
@@ -310,7 +326,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const updateDynamicVersion = async () => {
|
||||
try {
|
||||
const versionPath = document.documentElement.lang === 'de' ? '../version.json' : 'version.json';
|
||||
const versionPath = document.documentElement.lang === 'en' ? 'version.json' : '../version.json';
|
||||
const response = await fetch(versionPath);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
@@ -456,14 +472,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Dynamically localize home links on root dynamic pages (impressum, datenschutz, join)
|
||||
const localizeHomeLinks = () => {
|
||||
const activeLang = localStorage.getItem('koala_lang') || (navigator.language.startsWith('de') ? 'de' : 'en');
|
||||
const activeLang = safeGetLocalStorage('koala_lang') || (navigator.language.startsWith('de') ? 'de' : 'en');
|
||||
const path = window.location.pathname;
|
||||
const pathSegments = path.split('/');
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru'].includes(seg));
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'].includes(seg));
|
||||
|
||||
// Only need to do this dynamic rewrite if we are NOT already inside a localized subdirectory
|
||||
if (!isSubdir) {
|
||||
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="pt-BR/"], a[href="ru/"]');
|
||||
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="pt-BR/"], a[href="ru/"], a[href="it/"], a[href="pl/"], a[href="tr/"], a[href="nl/"], a[href="ja/"], a[href="ko/"], a[href="pt/"]');
|
||||
homeLinks.forEach(link => {
|
||||
link.href = (activeLang === 'en') ? './' : `${activeLang}/`;
|
||||
});
|
||||
@@ -477,16 +493,44 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const path = window.location.pathname;
|
||||
|
||||
// Save the user's preference
|
||||
localStorage.setItem('koala_lang', newLang);
|
||||
safeSetLocalStorage('koala_lang', newLang);
|
||||
|
||||
const isLegalImprint = path.includes('impressum') || path.includes('imprint');
|
||||
const isLegalPrivacy = path.includes('datenschutz') || path.includes('privacy');
|
||||
|
||||
if (isLegalImprint) {
|
||||
let target;
|
||||
const hasHtml = path.endsWith('.html');
|
||||
if (newLang === 'de') {
|
||||
target = hasHtml ? 'de/impressum.html' : 'de/impressum';
|
||||
if (path.includes('/de/')) target = hasHtml ? 'impressum.html' : 'impressum';
|
||||
} else {
|
||||
target = hasHtml ? 'imprint.html' : 'imprint';
|
||||
if (path.includes('/de/')) target = hasHtml ? '../imprint.html' : '../imprint';
|
||||
}
|
||||
window.location.href = target;
|
||||
return;
|
||||
} else if (isLegalPrivacy) {
|
||||
let target;
|
||||
const hasHtml = path.endsWith('.html');
|
||||
if (newLang === 'de') {
|
||||
target = hasHtml ? 'de/datenschutz.html' : 'de/datenschutz';
|
||||
if (path.includes('/de/')) target = hasHtml ? 'datenschutz.html' : 'datenschutz';
|
||||
} else {
|
||||
target = hasHtml ? 'privacy.html' : 'privacy';
|
||||
if (path.includes('/de/')) target = hasHtml ? '../privacy.html' : '../privacy';
|
||||
}
|
||||
window.location.href = target;
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if we are on a static landing page versus a dynamic utility page
|
||||
const isLegalOrJoin = path.includes('impressum') || path.includes('datenschutz') || path.includes('join');
|
||||
const isIndex = !isLegalOrJoin;
|
||||
const isIndex = !path.includes('join');
|
||||
|
||||
if (isIndex) {
|
||||
// Static navigation: Route to correct subdirectory
|
||||
const pathSegments = path.split('/');
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru'].includes(seg));
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'].includes(seg));
|
||||
|
||||
let targetPath;
|
||||
if (newLang === 'en') {
|
||||
@@ -509,7 +553,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
} else {
|
||||
// Dynamic page: Toggle classes and update elements dynamically without navigating away
|
||||
const html = document.documentElement;
|
||||
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-pt-br', 'lang-ru');
|
||||
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-pt-br', 'lang-ru', 'lang-it', 'lang-pl', 'lang-tr', 'lang-nl', 'lang-ja', 'lang-ko', 'lang-pt');
|
||||
|
||||
// Fallback dynamic pages to 'en' if 'de' is not chosen (since fr/es markup is not present)
|
||||
const activeDisplayLang = (newLang === 'de') ? 'de' : 'en';
|
||||
@@ -533,20 +577,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamically adjust language select width to fit the selected option's text length
|
||||
const adjustDropdownWidth = () => {
|
||||
document.querySelectorAll('.lang-dropdown').forEach(select => {
|
||||
const tempSpan = document.createElement('span');
|
||||
const style = window.getComputedStyle(select);
|
||||
tempSpan.style.fontFamily = style.fontFamily;
|
||||
tempSpan.style.fontSize = style.fontSize;
|
||||
tempSpan.style.fontWeight = style.fontWeight;
|
||||
tempSpan.style.visibility = 'hidden';
|
||||
tempSpan.style.position = 'absolute';
|
||||
tempSpan.style.whiteSpace = 'nowrap';
|
||||
|
||||
const activeOption = select.options[select.selectedIndex];
|
||||
if (activeOption) {
|
||||
tempSpan.textContent = activeOption.textContent;
|
||||
document.body.appendChild(tempSpan);
|
||||
const textWidth = tempSpan.getBoundingClientRect().width;
|
||||
select.style.width = (textWidth + 18) + 'px';
|
||||
document.body.removeChild(tempSpan);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Register change event listener for the dropdowns
|
||||
document.querySelectorAll('.lang-dropdown').forEach(select => {
|
||||
select.addEventListener('change', handleLanguageChange);
|
||||
select.addEventListener('change', (e) => {
|
||||
handleLanguageChange(e);
|
||||
adjustDropdownWidth();
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize language select elements to show the current preferred language
|
||||
const initLanguageSelectorValue = () => {
|
||||
const savedLang = localStorage.getItem('koala_lang');
|
||||
const savedLang = safeGetLocalStorage('koala_lang');
|
||||
const browserLang = navigator.language.startsWith('de') ? 'de' : 'en';
|
||||
const activePref = savedLang || browserLang;
|
||||
|
||||
document.querySelectorAll('.lang-dropdown').forEach(select => {
|
||||
select.value = activePref;
|
||||
});
|
||||
adjustDropdownWidth();
|
||||
};
|
||||
|
||||
// Impressum Email Obfuscation Click Reveal
|
||||
@@ -555,7 +626,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const user = this.getAttribute('data-user');
|
||||
const domain = this.getAttribute('data-domain');
|
||||
if (user && domain) {
|
||||
this.innerHTML = `${user}@${domain}`;
|
||||
this.textContent = `${user}@${domain}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.5 KiB |