Compare commits

..

81 Commits

Author SHA1 Message Date
Timo 34f0c2b265 chore(release): v2.2.3 — artifact attestations, bugfixes, rate limit metrics 2026-06-10 00:56:29 +02:00
Timo af59b4c64c ci(release): add website build + artifact upload on tag 2026-06-09 22:03:38 +02:00
Timo af8184420c chore: stop tracking website/www/ (auto-generated) 2026-06-09 22:02:39 +02:00
Timo 45e1e3defe chore: add website/www/ to gitignore 2026-06-09 22:01:48 +02:00
Timo e67b0c564d fix(website): copy apple-touch-icon files to www root for iOS/Safari 2026-06-09 22:00:31 +02:00
GitHub Action f54a38b656 chore(release): update versions to v2.2.2 [skip ci] 2026-06-09 19:16:15 +00:00
Timo 5216fde641 fix: prevent hero title descender clipping, replace <br> with styled <span> for second line across all locales 2026-06-09 21:14:55 +02:00
Timo 39fe2cbd11 fix: prevent hero title descender clipping by adding padding-bottom to h1 2026-06-09 20:55:18 +02:00
Timo 03d5dbda66 website: add Open Source (MIT) feature card, bump Volume Leveling to bento-large, add footer disclaimers, update translations for all 13 locales, bump version badge to v2.2.1 2026-06-09 20:53:18 +02:00
KoalaDev 0c0cbbc090 feat: add audio compressor to comparison table, fix schema version + languages, update sitemap, add version injection to workflow 2026-06-09 19:26:50 +02:00
KoalaDev 0f4ad39fb4 feat: add Chrome _locales for store i18n, fix copy logs alignment, extend locale test 2026-06-09 19:14:30 +02:00
KoalaDev cfbb070c57 feat: add audio compressor feature to website (Card 7, FAQ Q7/A7) across all 13 locales 2026-06-09 19:09:10 +02:00
KoalaDev 0caf19cf18 docs: fix CHANGELOG v2.2.0 — remove nonexistent makeup gain slider, correct preset count 2026-06-09 18:31:49 +02:00
KoalaDev ff31f56911 Revise KoalaSync description and core features
Updated the description and features of KoalaSync to emphasize privacy, ease of use, and support for various platforms.
2026-06-09 17:37:34 +02:00
KoalaDev 54d03df0af fix: add ping to copy debug report 2026-06-09 07:15:02 +02:00
GitHub Action 4f1016fa87 chore(release): update versions to v2.2.1 [skip ci] 2026-06-09 05:08:32 +00:00
KoalaDev 3e7afd7592 feat: add server ping display with peer ping foundation
Adds application-level ping/pong between extension and relay server.
Extension sends PING every 15s, server echoes PONG. Round-trip time
displayed in Status tab (green/yellow/red color-coded).

Server also forwards PING with target peerId and routes PONG back,
enabling future peer-to-peer ping without server restart.
Extension already responds to incoming peer PINGs.

See CHANGELOG.md for details.
2026-06-09 07:08:14 +02:00
KoalaDev 6c6d4dc2a2 fix status: Netflix no title, Disney+ partial title 2026-06-09 06:52:07 +02:00
KoalaDev 498d5bd91b clean up TESTED_SERVICES notes and fix contradictions 2026-06-09 06:39:53 +02:00
KoalaDev bfd1177a14 Update Netflix, Disney+, and Prime Video sync status 2026-06-09 06:31:33 +02:00
KoalaDev 15f4f5a12e Add rel="noopener" to all target="_blank" links (tabnabbing fix)
- template.html: 10 links fixed (propagates to 13 language variants)
- join.html, privacy.html, imprint.html, impressum-de.html, datenschutz-de.html
- app.js: 2 window.open() calls updated with 'noopener'
- Rebuilt site with node website/build.js

Preserves HTTP Referer — noreferrer intentionally omitted.
2026-06-09 02:40:03 +02:00
KoalaDev fa39be7d40 Remove noindex from legal pages, add them to sitemap 2026-06-09 01:53:22 +02:00
KoalaDev 5fd00f0bda Restore original attribution line in README.md 2026-06-08 23:13:54 +02:00
KoalaDev dca9c6ed07 Revise audio compressor description in README
Updated audio compressor feature description for clarity.
2026-06-08 21:45:23 +02:00
GitHub Action d8634744ea chore(release): update versions to v2.2.0 [skip ci] 2026-06-08 19:31:38 +00:00
Timo 2031f76bee fix: ru.json duplicate AUDIO_BACK, add dupe detection to locale tests 2026-06-08 21:31:06 +02:00
Timo 3757308117 chore: clean up v2.2.0 changelog - nur echte App-Änderungen 2026-06-08 21:21:22 +02:00
Timo e4fae56509 fix: README - New Release unter Beschreibung, Git-Regeln in AI_INIT.md 2026-06-08 21:16:31 +02:00
GitHub Action 766bc1760c chore(release): update versions to v2.2.0 [skip ci] 2026-06-08 19:11:56 +00:00
Timo 4949b04d07 README: restore audio compressor bullet, keep changelog link short 2026-06-08 21:11:31 +02:00
GitHub Action 6ba81d66a4 chore(release): update versions to v2.2.0 [skip ci] 2026-06-08 19:11:17 +00:00
Timo d5ed1083bc Revert README to minimal version bump only 2026-06-08 21:10:48 +02:00
GitHub Action 78d576c7f6 chore(release): update versions to v2.2.0 [skip ci] 2026-06-08 19:08:29 +00:00
Timo 08ce689f4c move all prefs from sync to local storage (audio, filter, autosync, etc.) 2026-06-08 21:07:40 +02:00
GitHub Action bf48d1889b chore(release): update versions to v2.2.0 [skip ci] 2026-06-08 19:01:27 +00:00
Timo 39788e5bd9 README: update for v2.2.0 release 2026-06-08 21:01:06 +02:00
Timo c134e1bfae swap review/coffee order in footers 2026-06-08 20:58:44 +02:00
Timo cad4b4a6db v2.2.0 polish: inline pulse dot, coffee theme, remove toggle, GitHub issue link, beautify audio page, locale cleanup 2026-06-08 20:56:41 +02:00
Timo 9e2abadf5a v2.2.0: audio compressor, Ko-Fi support, 11 new locale translations, website locale test 2026-06-08 20:36:49 +02:00
KoalaDev f5db39b77b Merge pull request #6 from Skrockle/main
Add support badges and audio processing
2026-06-08 19:56:14 +02:00
Skrockle 85d05b85c9 Avoid external support badge asset 2026-06-08 19:46:02 +02:00
Skrockle 6b438f2d37 Merge remote-tracking branch 'skrockle/main' 2026-06-08 19:34:27 +02:00
Skrockle 7bba36c505 Add support badges and audio processing 2026-06-08 19:25:07 +02:00
Timo f93937f5f3 docs: cleanup ROADMAP.md (remove outdated cache item, translate to English) + add store assets 2026-06-08 19:16:28 +02:00
KoalaDev da12dc07a7 move example configs to examples/ dir 2026-06-07 12:55:43 +02:00
KoalaDev 198064b720 docs: move AI_INIT.md, CHANGELOG.md, PRIVACY.md, TESTED_SERVICES.md to docs/ and update all references 2026-06-07 12:54:02 +02:00
KoalaDev def0ad6ded Update GitGem badge format in README.md 2026-06-07 12:31:12 +02:00
KoalaDev 026bbc65b2 Add GitGem badge to README
Added GitGem badge to README for repository visibility.
2026-06-07 12:21:30 +02:00
KoalaDev 854be35474 Add Ko-fi username for funding support 2026-06-07 03:31:50 +02:00
GitHub Action 3506eb0331 chore(release): update versions to v2.1.3 [skip ci] 2026-06-06 22:43:52 +00:00
Timo 0a34d804fb v2.1.3: Fix debug report showing oldest logs instead of newest 2026-06-07 00:43:25 +02:00
GitHub Action a1e882cfa7 chore(release): update versions to v2.1.2 [skip ci] 2026-06-06 20:16:46 +00:00
Timo c88f6fb121 v2.1.2
- Fixed episode guard regex to support Sxx:Exx title format (Jellyfin/Emby)
  and any non-alphanumeric separator between season and episode numbers
- Migrated username, roomId, password, serverUrl, useCustomServer from
  chrome.storage.sync to chrome.storage.local for per-device isolation,
  preventing automatic cross-device room joins with duplicate names
- Added one-time migration from sync to local for existing users
2026-06-06 22:16:30 +02:00
Timo fe047dea2e Marquee promo tile added 2026-06-04 17:52:46 +02:00
Timo 464f5f466a Merge branch 'main' of https://github.com/Shik3i/KoalaSync 2026-06-04 17:40:42 +02:00
Timo 9f00645f58 Updated CTA Store Screenshot 2026-06-04 17:40:34 +02:00
KoalaDev f95095f2cd Update StoreDescription.md with feature details 2026-06-04 17:35:16 +02:00
KoalaDev be91d07c56 Update StoreDescription.md with formatting changes 2026-06-04 17:30:12 +02:00
KoalaDev a6bc7cae48 Revise StoreDescription.md for KoalaSync
Updated the description of KoalaSync to enhance clarity and detail about features, privacy, and usage instructions.
2026-06-04 17:29:49 +02:00
Timo d0c4b3740d New store assets added. 2026-06-04 17:20:02 +02:00
Timo 67a7f6e663 style: remove globe-icon from language selector on website 2026-06-04 15:37:27 +02:00
GitHub Action 7499eafe4e chore(release): update versions to v2.1.1 [skip ci] 2026-06-04 13:33:30 +00:00
Timo 3fe8ca18e4 chore: release v2.1.1 2026-06-04 15:33:06 +02:00
GitHub Action f0ccc0c082 chore(release): update versions to v2.1.0 [skip ci] 2026-06-04 13:20:10 +00:00
Timo d59fc4777d chore: release v2.1.0 with stabilization and audit fixes 2026-06-04 15:18:57 +02:00
Timo 3ad2459558 docs: update TRANSLATION.md with new languages and flags 2026-06-04 15:02:27 +02:00
Timo 839f4d0761 docs: write changelog for v2.1.0 and update README.md 2026-06-04 15:01:14 +02:00
Timo 8838bf20b7 style: add flag emojis to all language selectors 2026-06-04 15:00:10 +02:00
Timo 9d849a2996 feat: add 7 new languages to extension and website, bump version to 2.1.0 2026-06-04 14:59:14 +02:00
Timo 6d5661ea45 feat: update icons for extension and website, automate responsive logo sizes in build pipeline 2026-06-04 14:46:55 +02:00
Koala 886550408a docs: align PRIVACY.md and website datenschutz with the server-only tracking statements 2026-06-03 13:46:11 +02:00
Koala 28694c22bb docs: expand privacy and no-logs statement in main README.md 2026-06-03 13:43:35 +02:00
Koala a2a56f2b17 fix(docs): remove unsupported type: gauge from json_exporter.example.yml 2026-06-03 13:34:19 +02:00
Koala 742876e415 docs: add json_exporter configuration example for prometheus monitoring 2026-06-03 13:08:41 +02:00
KoalaDev bcd956a8db Update README for v2.0.8 release announcement 2026-06-03 12:12:17 +02:00
GitHub Action 1e329c0c52 chore(release): update versions to v2.0.8 [skip ci] 2026-06-03 10:11:57 +00:00
Koala a7481d42a2 fix(extension): resolve language selector UI overwrite and dynamic version reporting 2026-06-03 12:09:39 +02:00
GitHub Action f9577aace9 chore(release): update versions to v2.0.7 [skip ci] 2026-06-03 09:58:23 +00:00
Koala ad33720053 feat(server): add DEBUG_LOGGING environment variable to suppress verbose console output 2026-06-03 11:57:58 +02:00
Koala fa3341cc8e docs(server): quote values in env.example and set empty token 2026-06-03 11:51:49 +02:00
GitHub Action b526e9287b chore(release): update versions to v2.0.6 [skip ci] 2026-06-03 09:45:12 +00:00
213 changed files with 6579 additions and 8771 deletions
+2
View File
@@ -0,0 +1,2 @@
# These are supported funding model platforms
ko_fi: koaladev
+36 -1
View File
@@ -11,6 +11,8 @@ jobs:
permissions:
contents: write
packages: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -35,6 +37,7 @@ jobs:
type=ref,event=tag
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
@@ -44,10 +47,19 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest@v4
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
release-extension:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -78,13 +90,21 @@ jobs:
jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json
echo " ✓ website/version.json -> version $VERSION, date $DATE"
# 5. website/template.html — SoftwareApplication schema
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
echo " ✓ website/template.html -> softwareVersion $VERSION"
# 6. README.md — version badge
sed -i "s/v[0-9]\+\.[0-9]\+\.[0-9]\+/v$VERSION/g" README.md
echo " ✓ README.md -> v$VERSION"
echo "Version injection complete."
- name: Commit and push version updates back to main
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add extension/manifest.base.json shared/constants.js package.json website/version.json
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
git push origin HEAD:main
env:
@@ -95,6 +115,21 @@ jobs:
npm install
npm run build:extension
- name: Generate artifact attestation for extensions
uses: actions/attest@v4
with:
subject-path: dist/koalasync-*.zip
- name: Build Website
run: node website/build.js
- name: Upload Website Artifacts
uses: actions/upload-artifact@v4
with:
name: website-www
path: website/www/
if-no-files-found: error
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
+3
View File
@@ -40,5 +40,8 @@ coverage/
# the root 'shared/' remains the Single Source of Truth.
extension/shared/
# Auto-generated website build output
website/www/
# Temporary scratch files
scratch/
-102
View File
@@ -1,102 +0,0 @@
# KoalaSync Changelog
All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.0.6] — 2026-06-03
### Performance & Security Hardening
- Optimized failed authentication attempts cache eviction algorithm to $O(1)$ by exploiting Javascript `Map` insertion-order properties. This completely removes the previous array copying and sorting bottleneck, neutralizing a potential main-thread blocking DoS vector under heavy brute-force password traffic.
---
## [v2.0.5] — 2026-06-03
### Security & Hardening
- Hardened extension room idle auto-leave detection to correctly recognize when the target tab's video heartbeat goes stale (e.g., after tab navigation or media closure).
- Exported cleaner graceful shutdown and lifecycle methods (`stopServerForTests`) from the relay server to prevent socket leaks and port-binding conflicts during verify checks.
### Added
- Added a validation step in `test-locales.js` to ensure the supported language list in `extension/i18n.js` is perfectly synchronized with the actual JSON translation files in the locales directory.
- Added a robust route verification test suite (`scripts/test-server-routes.mjs`) covering rate limit throttling, caching headers, and admin metrics access control.
---
## [v2.0.4] — 2026-06-03
### Security & Hardening
- Hardened relay health endpoints against simple flood traffic: `GET /` and `GET /health` are now limited to 10 requests per minute per client IP.
- Added lazy 60-second server-side caching for `GET /`, basic `/health`, and admin `/health` JSON responses to reduce repeated health-check work under noisy polling.
- Added stricter brute-force throttling for invalid admin metrics bearer attempts.
- Added startup warning for short `ADMIN_METRICS_TOKEN` values and documented that production Node ports must stay private behind Caddy or another trusted reverse proxy.
- Lowered the default maximum peers per room to 25.
### Added
- Optional privacy-preserving admin metrics on `/health` when `ADMIN_METRICS_TOKEN` is configured and a valid bearer token is supplied. Metrics are aggregate-only and exclude room IDs, peer IDs, usernames, IP addresses, media titles, passwords, and other user-level data.
### Changed
- Removed `bcryptjs`; temporary room passwords continue to use keyed SHA-256/HMAC hashing as documented.
- Public room discovery is now rate-limited server-side to one refresh every 10 seconds per socket, with the extension refresh button locked for 11 seconds.
### Fixed
- Improved Shadow DOM video detection so real embedded players are not hidden by smaller light-DOM preview or placeholder videos.
- Fixed join-button timeout cleanup after join status responses.
---
## [v2.0.2] — 2026-06-02
### Fixed
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
---
## [v2.0.1] — 2026-06-01
### Fixed
- Video detection on Prime Video: `findVideo()` now scores all video elements by size, duration, and mute state instead of picking the first one. Fixes 0×0 placeholder being selected over the actual player.
- History entries in debug report showing `?` instead of action names.
- Prime Video status in compatibility matrix updated to reflect partial support.
### Added
- Multi-video overview table in Copy Debug Report when a page has more than one `<video>` element. Shows resolution, mute state, playback state, readyState, duration, and marks the currently targeted video.
---
## [v2.0.0] — 2026-06-01
### 🌍 Multi-Language Extension (Biggest Feature!)
- **6-Language UI**: The browser extension is now fully translated into **English, German, French, Spanish, Portuguese (Brazilian), and Russian**. Switch languages instantly in Settings without reload.
- **Real-Time i18n**: Every label, button, tooltip, toast notification, empty state, and onboarding guide updates dynamically when the language changes.
### New Features
- **Copy Debug Report (Markdown)**: The *Copy Logs* button in the Status tab now copies a fully formatted Markdown debug report — system info, connection status, video diagnostics, action history, and logs. One click, paste into a GitHub issue, all debugging data ready.
- **Platform Auto-Detection**: The Dev tab now identifies streaming platforms (YouTube, Netflix, Twitch, Prime Video, Disney+, HBO Max, Vimeo, Dailymotion) and displays the detected platform.
- **Enhanced Video Debug Info**: 20+ new fields in the Status tab including network state, buffered ranges, dimensions (with 0×0 warning), media error codes, shadow DOM status, seeking/ended/loop flags, volume, playback speed, and data attributes.
- **No-Video Diagnostic Mode**: When no video is found, the Status tab shows platform, page title, video count, shadow DOM presence, and MediaSession data to help troubleshoot.
### Changed
- **New TwoPointZero Branding**: Updated extension icons (16/32/48/96/128px).
- **Larger Popup Logo**: Extension popup icon increased to 48px.
- **Prime Video Unblocked**: Removed `amazon.` from the tab blacklist so Amazon/Prime Video tabs appear in the video selector.
- **Improved Debug Report**: Full User-Agent string for accurate browser identification, UTC timestamp, connection details including server URL and room info.
- **Smart Disconnect**: Improved disconnect handling when leaving rooms.
- **Human-Readable Room IDs**: Expanded word lists for friendlier room names.
- **Custom Server Support**: WEB_JOIN_REQUEST and join button for custom server invite flows.
- **Reconnection Strategy**: Custom server reconnection improvements.
- **Episode-Aware Sync**: Command sequencing with smarter episode transition detection and echo suppression for smoother series binges.
- **Sync Status Refinements**: YouTube and Twitch sync behavior improved.
- **No External Dependencies**: Extension remains dependency-free with no library overhead.
### Fixed
- Hardcoded strings, missing translation keys, and Service Worker notification race conditions.
---
## Versioning Policy
- **MAJOR** (x.0.0): Breaking protocol changes, architecture rewrites, or major feature milestones.
- **MINOR** (0.x.0): New features, significant enhancements, new translations, or UI redesigns.
- **PATCH** (0.0.x): Bug fixes, minor improvements, and documentation updates. PATCH releases may not receive individual changelog entries if bundled with a MINOR release.
+36 -16
View File
@@ -12,13 +12,13 @@
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
</p>
<p align="center"><a href="CHANGELOG.md"><b>New v2.0.6 Release!</b> — See what's changed</a></p>
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.2.2 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture.
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture. We keep no logs of your sessions or synchronizations. *We don't track you. We only track our server* (relying on the [aggregated, anonymous, non-personal metrics](https://syncserver.koalastuff.net/health) provided under `/health`).
* **📡 Direct Logic**: Manual Socket.IO wire implementation for reliable synchronization.
* **🛠️ Clean Build**: Dependency-free extension runtime with no library overhead.
* **🌐 Universal**: Works on any website with a `<video>` tag.
@@ -33,6 +33,7 @@
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
- **Seamless Invitations**: Smart links that automatically configure server and room credentials for your friends.
- **Smart Audio Compressor**: Tired of constantly riding the volume? Automatically balance out whispering dialogue and deafening explosions with simple presets, or fully customize the audio to your liking.
---
@@ -53,6 +54,16 @@ The easiest and safest way to install KoalaSync is directly through the official
2. **Invite Friends:** Share the auto-copied invite link. Once they click it, they automatically join.
3. **Pick a Video:** Navigate to the Sync Tab, select the tab playing your video, and grab some popcorn! 🍿
---
### 🌐 Localization & Translations
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
- **Available Languages**: Support is included for 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.
---
### 🛠️ For Developers & Self-Hosters
@@ -81,44 +92,53 @@ Deploy your own private relay server using our official image:
docker pull ghcr.io/shik3i/koalasync:latest
# Or use our example compose file
cp docker-compose.caddy.example.yml docker-compose.yml
cp examples/docker-compose.caddy.example.yml docker-compose.yml
docker-compose up -d
```
The server will be available at `ws://localhost:3000`. See [Docker network compose](docker-compose.caddy.example.yml) or [Static IP compose](docker-compose.ip.example.yml) for ready-to-use Docker Compose files.
The server will be available at `ws://localhost:3000`. See [Docker network compose](examples/docker-compose.caddy.example.yml) or [Static IP compose](examples/docker-compose.ip.example.yml) for ready-to-use Docker Compose files.
To connect your extension to a self-hosted server, open the popup → **Room** tab → select **Custom Server** → enter your server's WebSocket URL (e.g., `ws://localhost:3000`).
> **⚠️ Note**: `ws://` only works for `localhost`. If you deploy to a real domain, you **must** use `wss://` (e.g., `wss://sync.yourdomain.com`). This requires a TLS-terminating reverse proxy (e.g., Caddy, Nginx, or Traefik) in front of the relay server. See [Caddyfile.example](Caddyfile.example) for a production-ready template.
> **⚠️ Note**: `ws://` only works for `localhost`. If you deploy to a real domain, you **must** use `wss://` (e.g., `wss://sync.yourdomain.com`). This requires a TLS-terminating reverse proxy (e.g., Caddy, Nginx, or Traefik) in front of the relay server. See [Caddyfile.example](examples/Caddyfile.example) for a production-ready template.
To verify your relay is reachable from outside, visit `https://your-domain.com` in a browser — it should return `{"status":"online","service":"KoalaSync Relay"}`.
---
#### Supply Chain Security (v2.2.2+)
### 🌐 Localization & Translations
All official release artifacts (Docker images and extension binaries) are published with signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) to prove they were built from this repository's source code.
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
- **Available Languages**: Support is included for 6 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), and Russian (`ru`).
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](website/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
**Verify a Docker image:**
```bash
gh attestation verify oci://ghcr.io/shik3i/koalasync:latest \
-R Shik3i/KoalaSync
```
**Verify an extension binary:**
```bash
gh attestation verify dist/koalasync-chrome.zip \
-R Shik3i/KoalaSync
```
---
### 📖 Documentation & Links
- **[CHANGELOG.md](CHANGELOG.md)**: Full version history for the extension and relay server.
- **[TESTED_SERVICES.md](TESTED_SERVICES.md)**: Detailed compatibility matrix of tested streaming platforms and known limitations.
- **[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><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>
<sub>Built with ❤️ by <a href="https://github.com/Shik3i">Shik3i</a>. KoalaSync is Open Source under the <a href="LICENSE">MIT License</a>.</sub>
</div>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+57
View File
@@ -0,0 +1,57 @@
KoalaSync: Private Watch Parties for Emby, Jellyfin, Plex, Netflix & YouTube
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube — KoalaSync is designed for smooth, browser-based watch parties.
✨ CORE FEATURES
No account required. No tracking. Just create a room, invite your friends, and start watching together.
• Real-Time Video Sync: Play, pause, seek, and watch together with fast synchronized playback across everyone in your room.
• No Account Needed: Create a room and share the invite link. No emails, no passwords, no sign-ups. Pick a nickname or let KoalaSync generate one for you.
• Works Almost Everywhere: If the website uses a standard HTML5 video player, KoalaSync can usually sync it. Perfect for streaming sites, self-hosted media servers, and other websites.
• Smart Binge-Watching: When a new episode loads, KoalaSync automatically pauses the lobby until everyone is ready. No spoilers, no one left behind.
• Smart Audio Compressor: Tired of quiet dialogue and suddenly loud action scenes? Balance whispering, explosions, and music with a single click while you watch.
• One-Click Invites: Send a smart invite link to your friends. When they open it, KoalaSync automatically configures the room so they can join instantly.
• 13 Languages: Enjoy a native experience with a fully translated user interface.
🛡️ PRIVACY & SECURITY
KoalaSync is built for private watch parties without unnecessary data collection.
• No Tracking: Zero analytics, zero telemetry, and absolutely no behavior profiling.
• Anonymous by Design: No accounts needed. Rooms can be joined with a simple nickname.
• Ready Out of the Box: Install KoalaSync and start watching immediately using the official public relay server. No technical setup required.
• RAM-Only Public Server: The official relay server operates entirely in volatile RAM. No databases, no stored watch history, no persistent room data. Room data exists only temporarily and disappears when the room closes.
• Self-Hostable: Want full control? You can run your own private KoalaSync relay server via Docker in seconds. Self-hosting is optional and never required.
🚀 HOW IT WORKS
1. Install KoalaSync.
2. Click "Create Room" to start a private watch party.
3. Share the invite link with your friends.
4. Open your favorite streaming site or media server.
5. Select the active video tab.
6. Press play — everyone stays perfectly in sync.
⚙️ UNDER THE HOOD
KoalaSync is lightweight, transparent, and built with privacy in mind.
• Real-Time Relay: Playback state is synchronized through a custom WebSocket-based relay server for fast room updates.
• No Media Streaming: KoalaSync does not stream, proxy, upload, download, or redistribute any video content. Everyone watches from their own browser on the original website.
• Temporary Room State Only: The relay server only coordinates room state such as play, pause, seek position, active target, nickname, and readiness status.
• Docker Self-Hosting: The relay server can be self-hosted with Docker if you prefer to run your own private instance.
• Open Architecture: The project is designed to be inspectable, forkable, and easy to review.
💻 OPEN SOURCE
KoalaSync was built by a solo developer who needed a fast, secure way to watch movies with friends. The code is fully transparent under the MIT license: audit it, fork it, improve it, or self-host your own relay server.
Found a bug or have a feature idea? Open an issue on GitHub. Contributions and code reviews are always welcome.
• Website: https://sync.koalastuff.net
• GitHub: https://github.com/Shik3i/KoalaSync
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+13 -4
View File
@@ -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,6 +100,15 @@ The following features are critical and must not be removed or fundamentally alt
## 10. Common Workflows
## CRITICAL: Git & Release Rules
- **NEVER** push, commit, tag, or release without explicit user instruction.
- **NEVER** retag or force-push without explicit instruction.
- **NEVER** create tags for documentation-only or README changes.
- **NEVER** run `git push`, `git tag`, `git commit` unless the user says "push", "commit", or "tag".
- Only the user decides when to commit, push, tag, or release.
- Ask before any git write operation.
### ⚠️ Pre-Session Git Sync (MANDATORY)
Before starting any task, committing, or pushing, you **MUST** run `git pull --rebase` to ensure your local branch is up-to-date with `origin/main`. CI pipelines and other agents may push commits concurrently. Skipping this step will cause merge conflicts and rejected pushes.
+212
View File
@@ -0,0 +1,212 @@
# KoalaSync Changelog
All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.2.3] — 2026-06-10
### Added
- **Artifact Attestations (Supply Chain Security)**: All release artifacts (Docker images, extension ZIPs) are now published with signed [SLSA provenance attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) via `actions/attest@v4`. Anyone can verify that an artifact was built from this repository using `gh attestation verify`.
- **Admin health: `rateLimits.denied` counters**: New rolling counters track actual rate-limit denials (429 responses), separate from `rateLimits.trackedClients` which reports unique IPs in the tracking window.
- **Docker HEALTHCHECK**: Container health is now checked every 30s via `GET /health`.
- **`npm start` script**: Server can now be started with `npm start`.
### Fixed
- **Server: `activeLobby` no longer silently overwritten**: If a second peer sends `EPISODE_LOBBY` while a lobby is already active, the request is now ignored instead of destroying the first peer's lobby.
- **CORS log sanitization**: Rejected origin headers are sanitized (`\r\n` stripped) to prevent log injection.
- **Extension: pagehide resource leak**: `keepAlivePort`, `lobbyPollTimer`, heartbeats, and `MutationObserver` are now properly cleaned up when a tab is hidden or enters bfcache.
- **Extension: unhandled storage rejections**: `chrome.storage.session.set()` calls in the disconnect handler now have `.catch(() => {})`.
- **`'Pixel'` duplicate in name generator**: Second occurrence replaced with `'Nitro'` for better name diversity.
- **`'opposum'` typo**: Corrected to `'opossum'` in the emoji map and added `'Opossum'` to `USERNAME_NOUNS`.
- **Test reliability**: `test-server-routes.mjs` now sets `ADMIN_METRICS_TOKEN` before importing the server module, fixing standalone test execution.
- **`MAX_PEERS_PER_ROOM` konsistent**: `.env` auf 25 gesetzt (wie `.env.example`).
- **pt-BR.json duplicate removed**: Duplicate `FOOTER_DISCLAIMER` key removed from website locale.
### Changed
- **Admin health: `rateLimitEntries` renamed to `rateLimits.trackedClients`**: The field now accurately describes that it tracks unique clients in the rate-limit window, not denial counts. Update your json_exporter/Grafana config accordingly.
- **README restructured**: Sections reordered by progressive technical depth. New "Supply Chain Security" subsection under "For Developers & Self-Hosters" with verification commands.
---
## [v2.2.2] — 2026-06-09
### Added
- **Chrome Web Store i18n Support**: Added `default_locale: "en"` to manifest and created `_locales/*/messages.json` for all 13 supported languages. This unlocks the language selection dropdown in the Chrome Web Store dashboard, allowing translated store listings (title, description) per locale. The extension's own UI translations (`locales/*.json` + `i18n.js`) remain unchanged.
- **Locale test coverage**: Extended `scripts/test-locales.js` to validate all `_locales/*/messages.json` files (correct format, required keys, no duplicates) and verify `default_locale` is set in the manifest.
### Fixed
- **Copy Logs button alignment**: Removed stray `margin-top: 8px` inherited from `.secondary` class that pushed the button 8px down in the connection status row.
---
## [v2.2.1] — 2026-06-09
### Added
- **Server Ping Display**: Measures round-trip latency to the relay server via application-level ping/pong events. The extension sends `PING { t }` every 15 seconds; the server responds with `PONG { t }`. Round-trip time is calculated client-side and displayed in the Status tab, color-coded (<50ms green, 50150ms yellow, >150ms red). No ping value is shown when disconnected or if the server does not respond within 5 seconds.
- **Peer Ping Response (Future-Proof)**: The extension can now respond to incoming `PING { t, sender }` events from other peers by sending back `PONG { t, target: sender }`. The relay server forwards `PING` to the target peer and routes `PONG` back to the original sender. Both client and server validate that peers are in the same room before forwarding/routing. Peer-to-peer ping initiation will be activated in a future extension update without requiring a server restart.
---
## [v2.2.0] — 2026-06-08
### Added
- **Web Audio API Compressor**: Built-in audio dynamic range compression with four presets (Recommended, Dynamic Range, Vocal Enhancement, Smooth) and fully customizable sliders (threshold, ratio, knee, attack, release). Uses dry/wet crossfade (40ms linear ramp) to avoid clicks. Configured via the new Audio Options page accessible from the Settings tab.
- **Audio Options Page** (`audio-options.html`): Dedicated settings page with master toggle, compressor preset selector, real-time custom sliders, and equalizer placeholder. Dark theme matching the popup design.
- **Feature Hint System**: Generic `dismissedHints` array in sync storage for announcing new features. First hint highlights the Audio Options entry in Settings. Extensible for future features.
### Changed
- **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.
+2
View File
@@ -2,6 +2,8 @@
**KoalaSync does not collect, store, or sell any personal data.**
*We don't track you. We only track our server* (relying exclusively on aggregated, anonymous, and non-personal system metrics to monitor performance and stability).
KoalaSync is designed with a **Security-First & Volatile** architecture. This means we prioritize keeping your data out of persistent storage, though certain technical data must be processed temporarily to ensure service stability and security.
## 1. Data Processing (In-Memory Only)
+8 -16
View File
@@ -1,22 +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.
### 2. Aufteilung großer JavaScript-Dateien (> 800 Zeilen) in kleinere Module
* **Kategorie**: Wartbarkeit / AI-Kontext-Optimierung
* **Hintergrund**: Einige Kern-Dateien wie `background.js` und `popup.js` sind stark angewachsen und überschreiten 800 Zeilen. Dies erschwert das manuelle Debugging und verbraucht unnötig viel Kontextfenster bei AI-Modellen.
* **Geplante Lösung**:
- Strukturierte Aufteilung der Logik in separate, fokussierte Module (z. B. UI-Renderer, Message-Router, Storage-Manager, Socket-Client).
- Nutzung von ES-Modulen zur sauberen Strukturierung und besseren Wiederverwendbarkeit.
### 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.
+10 -10
View File
@@ -4,19 +4,19 @@ This document tracks which streaming platforms and media servers have been teste
| Service | Sync Works | Media Title | Episode Auto-Sync | Notes |
|---------|:----------:|:-----------:|:-----------------:|-------|
| **YouTube** | ✅ Full | ✅ Full | ❌ | Best-in-class support. Native player API, reliable title detection. |
| **Twitch** | ✅ Full | ✅ Full | ❌ | Live-only platform. Tested on regular streams. |
| **Netflix** | ✅ Full | ⚠️ Hidden | ⚠️ Manual | Sync works perfectly, but DRM prevents media title detection. Episode transitions may require manual lobby. |
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
| **Plex** | Not tested | Not tested | Not tested | Community reports indicate compatibility via HTML5 player mode. |
| **Disney+** | Not tested | Not tested | Not tested | Widevine DRM may restrict title detection similar to Netflix. |
| **Prime Video** | ⚠️ Partial | ⚠️ Partial | ❌ | Video elements detected (2 on page, picks larger one). Playback state + time readable. However, the preview/trailer video may be selected instead of the main content. Play/Pause commands may not reach the correct player. Title detection from MediaSession API may work for some content. |
| **YouTube** | ✅ Full | ✅ Full | ❌ | Individual videos, not episodes — no episode auto-sync. |
| **Twitch** | ✅ Full | ✅ Full | ❌ | Individual streams/VODs, not episodes — no episode auto-sync. |
| **Netflix** | ✅ Full | ❌ | ❌ | No media title exposed. |
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Best-in-class support. |
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | |
| **Plex** | Not tested | Not tested | Not tested | |
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | Series title only (e.g. "The Simpsons"), no episode info. |
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — |
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
| **Vimeo** | Not tested | Not tested | Not tested | — |
| **Dailymotion** | Not tested | Not tested | Not tested | — |
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | German public broadcasters. HTML5 players expected to work. |
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | |
## Legend
@@ -39,7 +39,7 @@ Tested a service that's not listed? Found different behavior than documented?
KoalaSync works on any website with a **standard HTML5 `<video>` element** that allows script injection.
Limited functionality on certain platforms is typically caused by:
- **DRM/Copy Protection** (e.g., Widevine on Netflix, Disney+) which restricts access to media metadata like title and playback state
- **DRM/Copy Protection** (e.g., Widevine on Netflix) which restricts access to media metadata like title and playback state
- **Shadow DOM encapsulation** that hides video elements from content scripts
- **Strict Content Security Policies** (CSP) that block script injection
+104
View File
@@ -0,0 +1,104 @@
# Prometheus Community JSON Exporter Configuration Example
# File: examples/json_exporter.example.yml
#
# Use this configuration to map KoalaSync admin health metrics (JSON)
# to native Prometheus metrics.
#
# Usage:
# 1. Rename this file to json_exporter.yml
# 2. Replace "YOUR_ADMIN_METRICS_TOKEN" with your actual ADMIN_METRICS_TOKEN env value
# 3. Mount it to the json-exporter docker container: /config.yml
modules:
koalasync:
http_client_config:
bearer_token: "YOUR_ADMIN_METRICS_TOKEN"
metrics:
- name: koalasync_uptime_seconds
path: '{.uptime}'
help: "Uptime of the KoalaSync relay server in seconds"
- name: koalasync_rooms
path: '{.rooms}'
help: "Total active rooms"
- name: koalasync_connections
path: '{.connections}'
help: "Total active socket connections (sockets)"
- name: koalasync_peers
path: '{.peers}'
help: "Total connected peers across all rooms"
- name: koalasync_rooms_with_lobby
path: '{.roomsWithLobby}'
help: "Number of rooms waiting in an episode lobby"
- name: koalasync_avg_peers_per_room
path: '{.avgPeersPerRoom}'
help: "Average number of peers per room"
- name: koalasync_max_peers_in_room
path: '{.maxPeersInRoom}'
help: "Maximum number of peers in a single room"
- name: koalasync_memory_rss_bytes
path: '{.memory.rss}'
help: "Resident Set Size (RSS) memory usage in bytes"
- name: koalasync_memory_heap_used_bytes
path: '{.memory.heapUsed}'
help: "V8 engine heap used in bytes"
- name: koalasync_memory_heap_total_bytes
path: '{.memory.heapTotal}'
help: "V8 engine heap total in bytes"
# Rate limiter tracking — unique clients currently in each tracking window
# (not rate-limit denials; these include legitimate traffic too)
- name: koalasync_rate_limit_connections
path: '{.rateLimits.trackedClients.connections}'
help: "Unique clients tracked in the connection rate limiter window"
- name: koalasync_rate_limit_events
path: '{.rateLimits.trackedClients.events}'
help: "Unique sockets tracked in the event rate limiter window"
- name: koalasync_rate_limit_health
path: '{.rateLimits.trackedClients.health}'
help: "Unique IPs tracked in the health endpoint rate limiter window"
- name: koalasync_rate_limit_admin_metrics_auth
path: '{.rateLimits.trackedClients.adminMetricsAuth}'
help: "Unique IPs tracked in the admin metrics auth rate limiter window"
- name: koalasync_rate_limit_auth_failures
path: '{.rateLimits.trackedClients.authFailures}'
help: "Unique IPs tracked in the authentication failures cache"
- name: koalasync_rate_limit_room_list
path: '{.rateLimits.trackedClients.roomList}'
help: "Unique sockets in the room list cooldown cache"
# Actual rate-limit denials — incremented only when a 429 is served
- name: koalasync_rate_limit_denied_connections
path: '{.rateLimits.denied.connections}'
help: "Total connection attempts denied by rate limiter"
- name: koalasync_rate_limit_denied_events
path: '{.rateLimits.denied.events}'
help: "Total socket events denied by rate limiter"
- name: koalasync_rate_limit_denied_health
path: '{.rateLimits.denied.health}'
help: "Total health endpoint requests denied by rate limiter"
- name: koalasync_rate_limit_denied_admin_metrics_auth
path: '{.rateLimits.denied.adminMetricsAuth}'
help: "Total admin metrics auth attempts denied by rate limiter"
- name: koalasync_rate_limit_denied_room_list
path: '{.rateLimits.denied.roomList}'
help: "Total room list refresh requests denied by rate limiter"
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincroniza la reproducción de video en YouTube, Netflix, Emby, Jellyfin y cualquier sitio HTML5 en tiempo real con amigos."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin 및 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
}
}
Binary file not shown.
+247
View File
@@ -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;
}
}
+104
View File
@@ -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>
+196
View File
@@ -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);
});
+173 -65
View File
@@ -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 ---
@@ -23,6 +23,12 @@ const lastSeqBySender = {}; // senderId → last received seq (sta
const activePorts = new Set(); // New: track active content ports for keep-alive
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
// --- Ping / Latency ---
let pingInterval = null;
let pingTimeout = null;
let pendingPingT = null;
let currentPingMs = null;
// --- Keep-Alive Port Listener ---
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'keepAlive') {
@@ -230,35 +236,38 @@ async function getPeerId() {
}
async function getSettings() {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
let username = data.username;
if (!username) {
username = generateUsername();
chrome.storage.sync.set({ username }, () => {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username: username
});
});
} else {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username: username
});
}
// Try local (per-device) first, fall back to sync for migration
let data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
let migrated = false;
if (!data.username) {
const syncData = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
if (syncData.username || syncData.roomId) {
data = syncData;
migrated = true;
}
}
let username = data.username;
if (!username) {
username = generateUsername();
}
if (migrated) {
await chrome.storage.local.set({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username
});
});
} else if (!data.username) {
await chrome.storage.local.set({ username });
}
return {
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username
};
}
function addLog(message, type = 'info') {
@@ -296,6 +305,7 @@ function forceDisconnect() {
clearTimeout(forceSyncTimeout);
forceSyncTimeout = null;
}
stopPing();
if (socket) {
socket.onopen = null;
socket.onmessage = null;
@@ -382,7 +392,7 @@ async function leaveRoomAfterIdleGrace(reason) {
lastContentHeartbeatAt: null,
episodeLobby: null
}).catch(() => {});
await chrome.storage.sync.set({ roomId: '', password: '' }).catch(() => {});
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
addLog(reason, 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
updateBadgeStatus();
@@ -457,7 +467,7 @@ async function connect() {
url.pathname = '/socket.io/';
url.searchParams.set('EIO', '4');
url.searchParams.set('transport', 'websocket');
url.searchParams.set('version', APP_VERSION);
url.searchParams.set('version', chrome.runtime.getManifest().version);
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
socket = new WebSocket(url.toString());
@@ -489,6 +499,7 @@ async function connect() {
isConnecting = false;
isNamespaceJoined = true;
broadcastConnectionStatus('connected');
startPing();
addLog('Joined Namespace /', 'success');
const settings = await getSettings();
if (settings.roomId) {
@@ -524,6 +535,7 @@ async function connect() {
socket.onclose = () => {
isConnecting = false;
isNamespaceJoined = false;
stopPing();
isForceSyncInitiator = false;
forceSyncAcks.clear();
@@ -532,12 +544,12 @@ async function connect() {
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
});
}).catch(() => {});
if (currentRoom) {
currentRoom.peers = [];
if (storageInitialized) chrome.storage.session.set({ currentRoom });
if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {});
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
@@ -588,14 +600,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 = '';
@@ -692,8 +700,48 @@ function addToHistory(action, senderId) {
chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {});
}
// --- Ping / Latency ---
function sendPing() {
const t = Date.now();
pendingPingT = t;
emit(EVENTS.PING, { t });
if (pingTimeout) clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {
if (pendingPingT === t) {
pendingPingT = null;
}
pingTimeout = null;
}, 5000);
}
function startPing() {
if (pingInterval) clearInterval(pingInterval);
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
currentPingMs = null;
pendingPingT = null;
pingInterval = setInterval(sendPing, 15000);
sendPing();
}
function stopPing() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
currentPingMs = null;
pendingPingT = null;
}
// --- 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;
@@ -761,12 +809,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',
@@ -876,6 +920,11 @@ function handleServerEvent(event, data) {
routeToContent(event, data);
break;
case EVENTS.PING:
if (data && typeof data.t === 'number' && Number.isFinite(data.t) && data.sender) {
emit(EVENTS.PONG, { t: data.t, target: data.sender });
}
break;
case EVENTS.EVENT_ACK:
if (lastActionState && lastActionState.action && data?.senderId) {
// Correlation Check: Only accept ACK if it matches our current action's timestamp
@@ -1012,6 +1061,20 @@ function handleServerEvent(event, data) {
addLog(`Episode lobby for "${title}" cancelled by ${data.senderId || 'peer'}`, 'warn');
}
break;
case EVENTS.PONG:
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
if (pendingPingT === data.t) {
pendingPingT = null;
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
const rtt = Date.now() - data.t;
currentPingMs = (rtt >= 0 && rtt < 30000) ? rtt : null;
chrome.runtime.sendMessage({ type: 'PING_UPDATE', ping: currentPingMs }).catch(() => {});
}
}
break;
default:
addLog(`Received unknown event from server: ${event}`, 'warn');
break;
@@ -1097,14 +1160,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];
@@ -1297,6 +1356,27 @@ function leaveOldRoomIfSwitching(newRoomId) {
}
}
function resetAudioProcessingInTab(tabId) {
if (!tabId) return;
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
}
async function applyAudioSettingsToTab(tabId) {
if (!tabId) return;
let data = (await chrome.storage.local.get(['audioSettings']));
if (!data.audioSettings) {
const syncData = await chrome.storage.sync.get(['audioSettings']);
if (syncData.audioSettings) {
data = syncData;
await chrome.storage.local.set({ audioSettings: syncData.audioSettings });
}
}
chrome.tabs.sendMessage(tabId, {
action: 'APPLY_AUDIO_SETTINGS',
settings: data.audioSettings
}).catch(() => {});
}
// --- Extension Message Listeners ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
handleAsyncMessage(message, sender, sendResponse);
@@ -1304,6 +1384,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
async function handleAsyncMessage(message, sender, sendResponse) {
if (!message) return;
await ensureState();
if (message.type === 'CONNECT') {
@@ -1353,11 +1434,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
reconnectSlowMode: reconnectFailed,
roomId: currentRoom ? currentRoom.roomId : null,
serverUrl: currentServerUrl,
version: APP_VERSION,
version: chrome.runtime.getManifest().version,
protocolVersion: PROTOCOL_VERSION,
roomPassword: currentRoom ? currentRoom.password : null
roomPassword: currentRoom ? currentRoom.password : null,
ping: currentPingMs
});
} else if (message.type === 'LEAVE_ROOM') {
resetAudioProcessingInTab(currentTabId);
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
currentTabId = null;
@@ -1403,7 +1486,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,
@@ -1460,15 +1543,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) {
@@ -1599,6 +1686,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
});
} else if (message.type === 'SET_TARGET_TAB') {
const previousTabId = currentTabId;
currentTabId = message.tabId;
currentTabTitle = message.tabTitle;
lastContentHeartbeatAt = null;
@@ -1607,14 +1695,21 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt });
updateBadgeStatus();
if (previousTabId && previousTabId !== currentTabId) {
resetAudioProcessingInTab(previousTabId);
}
if (currentTabId) {
const selectedTabId = currentTabId;
chrome.scripting.executeScript({
target: { tabId: currentTabId },
target: { tabId: selectedTabId },
files: ['content.js']
}).catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
});
})
.then(() => applyAudioSettingsToTab(selectedTabId))
.catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
});
}
sendResponse({ status: 'ok' });
@@ -1638,7 +1733,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
// Check setting
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
if (epSettings.autoSyncNextEpisode === false) {
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
sendResponse({ status: 'disabled' });
@@ -1724,6 +1819,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();
@@ -1775,7 +1881,9 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).catch(() => {});
})
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
}
});
+3 -1
View File
@@ -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
+207 -9
View File
@@ -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;
}
});
@@ -114,6 +136,147 @@
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 ---
function getMediaTitle() {
return (navigator.mediaSession && navigator.mediaSession.metadata)
@@ -126,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);
@@ -344,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;
@@ -366,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;
}
@@ -395,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) {
@@ -622,7 +806,7 @@
mediaTitle: mediaTitle,
timestamp: Date.now()
}
});
}).catch(() => {});
// Trigger proactive heartbeat to push stabilized state
scheduleProactiveHeartbeat();
@@ -650,7 +834,15 @@
});
// Reset on page hide/show (bfcache, tab discard)
window.addEventListener('pagehide', () => { pageVisible = false; });
window.addEventListener('pagehide', () => {
pageVisible = false;
closeAudioContext();
if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; }
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
observer.disconnect();
});
window.addEventListener('pageshow', (event) => {
// event.persisted is true ONLY when restored from bfcache, not on initial load
if (event.persisted && !pageVisible) {
@@ -717,6 +909,9 @@
function setupListeners() {
const video = findVideo();
if (video) {
if (currentAudioVideo && currentAudioVideo !== video) {
bypassCurrentAudioProcessing();
}
const existing = video._koalaHandlers;
if (existing) {
video.removeEventListener('play', existing.play);
@@ -736,6 +931,8 @@
if (!lastKnownMediaTitle) {
lastKnownMediaTitle = getMediaTitle();
}
if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
}
}
@@ -750,6 +947,7 @@
if (!video && lastVideoSrc !== undefined) {
reportLog('Video element removed from page', 'warn');
lastVideoSrc = undefined;
closeAudioContext();
return;
}
+54 -7
View File
@@ -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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+27 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Peer-ID neu generieren",
"BTN_REGEN_ID_TOOLTIP": "Generiere deine interne ID neu und verbinde dich erneut",
"REGEN_ID_DESC": "Verwende dies, wenn du Fehler wegen doppelter Identität siehst.",
"REGEN_ID_OTHER_ISSUE": "Anderes Problem? Öffne ein GitHub Issue",
"LABEL_CONN_STATUS": "Verbindungsstatus",
"LABEL_CONN_STATUS_TOOLTIP": "Aktueller WebSocket-Verbindungsstatus",
"CONN_STATUS_DISCONNECTED": "Getrennt",
@@ -179,5 +180,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"
}
+27 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Regenerate Peer ID",
"BTN_REGEN_ID_TOOLTIP": "Regenerate your internal ID and reconnect",
"REGEN_ID_DESC": "Use this if you see \"Duplicate Identity\" errors.",
"REGEN_ID_OTHER_ISSUE": "Other issue? Open a GitHub Issue",
"LABEL_CONN_STATUS": "Connection Status",
"LABEL_CONN_STATUS_TOOLTIP": "Current WebSocket connection state",
"CONN_STATUS_DISCONNECTED": "Disconnected",
@@ -179,5 +180,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"
}
+27 -1
View File
@@ -91,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",
@@ -179,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"
}
+27 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Régénérer l'identifiant",
"BTN_REGEN_ID_TOOLTIP": "Régénérer votre identifiant interne et vous reconnecter",
"REGEN_ID_DESC": "Utilisez cette option si vous rencontrez des erreurs de 'Double identité'.",
"REGEN_ID_OTHER_ISSUE": "Autre problème? Ouvrez un Issue GitHub",
"LABEL_CONN_STATUS": "Statut de la connexion",
"LABEL_CONN_STATUS_TOOLTIP": "Statut actuel de la connexion WebSocket",
"CONN_STATUS_DISCONNECTED": "Déconnecté",
@@ -179,5 +180,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"
}
+209
View File
@@ -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"
}
+209
View File
@@ -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": "近日公開"
}
+209
View File
@@ -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": "출시 예정"
}
+209
View File
@@ -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"
}
+209
View File
@@ -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"
}
+27 -1
View File
@@ -91,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",
@@ -179,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"
}
+209
View File
@@ -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"
}
+27 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Сбросить Peer ID",
"BTN_REGEN_ID_TOOLTIP": "Сбросить ваш внутренний идентификатор и переподключиться",
"REGEN_ID_DESC": "Используйте при появлении ошибок дублирования идентификации.",
"REGEN_ID_OTHER_ISSUE": "Другая проблема? Откройте Issue на GitHub",
"LABEL_CONN_STATUS": "Статус подключения",
"LABEL_CONN_STATUS_TOOLTIP": "Текущий статус WebSocket-соединения с сервером",
"CONN_STATUS_DISCONNECTED": "Отключено",
@@ -179,5 +180,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": "Скоро"
}
+209
View File
@@ -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"
}
+2 -1
View File
@@ -1,7 +1,8 @@
{
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.0.5",
"version": "2.2.2",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
+102 -16
View File
@@ -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,14 @@
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
<span id="connDot" class="status-dot status-offline"></span>
<span id="connText" style="flex:1;" data-i18n="CONN_STATUS_DISCONNECTED">Disconnected</span>
<span id="connText" style="flex:1;">Disconnected</span>
<span id="connPing" style="font-size:11px; font-family:monospace; font-weight:600; opacity:0.8;"></span>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px; margin: 0;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
</div>
<label title="Technical details about the currently selected video element" data-i18n="LABEL_VIDEO_DEBUG" data-i18n-title="LABEL_VIDEO_DEBUG_TOOLTIP">Video Debug Info</label>
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;" data-i18n="VIDEO_DEBUG_EMPTY">
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;">
No tab selected or video detected.
</div>
@@ -542,9 +621,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>
+190 -44
View File
@@ -1,7 +1,7 @@
import { EVENTS, OFFICIAL_LANDING_PAGE_URL } from './shared/constants.js';
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage } from './i18n.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
const elements = {
@@ -16,6 +16,7 @@ const elements = {
clearLogs: document.getElementById('clearLogs'),
connDot: document.getElementById('connDot'),
connText: document.getElementById('connText'),
connPing: document.getElementById('connPing'),
serverUrl: document.getElementById('serverUrl'),
serverOfficial: document.getElementById('serverOfficial'),
serverCustom: document.getElementById('serverCustom'),
@@ -50,9 +51,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;
@@ -71,8 +79,65 @@ let pendingConnectionErrorMsg = null;
let roomListRefreshTimer = null;
let roomListRefreshInterval = null;
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
const FEATURE_HINTS = [
{
key: 'audio_processing',
selector: '#audioProcessingLabel',
position: 'beforebegin',
i18nTooltip: 'NEW_FEATURE_AUDIO'
}
];
// --- Helpers ---
function configureFooterLinks() {
const reviewUrl = getReviewUrl();
[elements.settingsSupportLink, elements.devSupportLink].forEach(link => {
if (link) link.href = SUPPORT_URL;
});
[elements.settingsReviewLink, elements.devReviewLink].forEach(link => {
if (link) link.href = reviewUrl;
});
}
function openAudioSettingsPage() {
chrome.tabs.create({ url: chrome.runtime.getURL('audio-options.html') });
}
async function updateFeatureHints() {
const data = await chrome.storage.sync.get(['dismissedHints']);
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
FEATURE_HINTS.forEach(feature => {
const target = document.querySelector(feature.selector);
if (!target) return;
const existing = target.parentElement?.querySelector(`.feature-hint[data-feature="${feature.key}"]`);
if (dismissed.includes(feature.key)) {
if (existing) existing.remove();
return;
}
if (!existing) {
const hint = document.createElement('span');
hint.className = 'feature-hint';
hint.dataset.feature = feature.key;
hint.title = getMessage(feature.i18nTooltip);
target.insertAdjacentElement(feature.position || 'afterend', hint);
} else {
existing.title = getMessage(feature.i18nTooltip);
}
});
}
async function dismissFeatureHint(featureKey) {
const data = await chrome.storage.sync.get(['dismissedHints']);
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
if (!dismissed.includes(featureKey)) {
await chrome.storage.sync.set({ dismissedHints: [...dismissed, featureKey] });
}
await updateFeatureHints();
}
function clearConnectionErrorTimer() {
if (connectionErrorTimer) {
clearTimeout(connectionErrorTimer);
@@ -108,14 +173,24 @@ function setRoomRefreshCooldown() {
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale']);
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
// Migrate preferences from sync → local for existing users
const oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
const toMigrate = {};
for (const key of ['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']) {
if (localData[key] === undefined && oldSync[key] !== undefined) {
toMigrate[key] = oldSync[key];
localData[key] = oldSync[key];
}
}
if (Object.keys(toMigrate).length) {
await chrome.storage.local.set(toMigrate);
}
let activeLang = data.locale;
let activeLang = localData.locale;
if (!activeLang) {
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
chrome.storage.sync.set({ locale: activeLang });
activeLang = getSystemLanguage();
chrome.storage.local.set({ locale: activeLang });
}
await loadLocale(activeLang);
@@ -123,21 +198,21 @@ async function init() {
if (elements.langSelector) elements.langSelector.value = activeLang;
let username = data.username;
let username = localData.username;
if (!username) {
username = generateUsername();
chrome.storage.sync.set({ username });
await chrome.storage.local.set({ username });
}
elements.serverUrl.value = data.serverUrl || '';
elements.roomId.value = data.roomId || '';
elements.password.value = data.password || '';
elements.serverUrl.value = localData.serverUrl || '';
elements.roomId.value = localData.roomId || '';
elements.password.value = localData.password || '';
elements.username.value = username;
if (elements.filterNoise) elements.filterNoise.checked = data.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
if (elements.forceSyncMode) elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
if (elements.browserNotifications) elements.browserNotifications.checked = data.browserNotifications === true;
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = data.autoCopyInvite !== false;
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
if (elements.forceSyncMode) elements.forceSyncMode.value = localData.forceSyncMode || 'jump-to-others';
if (elements.browserNotifications) elements.browserNotifications.checked = localData.browserNotifications === true;
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = localData.autoCopyInvite !== false;
// Set Version Info
const versionTxt = `v${chrome.runtime.getManifest().version}`;
@@ -145,18 +220,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) {
@@ -168,6 +249,7 @@ async function init() {
localPeerId = res.peerId;
reconnectSlowMode = res.reconnectSlowMode || false;
applyConnectionStatus(res.status);
updatePingDisplay(res.ping);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
@@ -571,7 +653,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const token = {};
populateTabsToken = token;
const data = await chrome.storage.sync.get(['filterNoise']);
const data = await chrome.storage.local.get(['filterNoise']);
const isFilterActive = data.filterNoise !== false;
let currentTargetTabId = providedTargetTabId;
@@ -732,6 +814,9 @@ function applyConnectionStatus(status) {
if (elements.connText) {
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : getMessage('STATUS_DISCONNECTED')));
}
if (!connected) {
updatePingDisplay(null);
}
if (elements.retryBtn) {
elements.retryBtn.style.display = reconnecting && reconnectSlowMode ? 'block' : 'none';
}
@@ -751,6 +836,23 @@ function applyConnectionStatus(status) {
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = getMessage('BTN_SYNC');
}
function updatePingDisplay(pingMs) {
if (!elements.connPing) return;
if (pingMs === null || pingMs === undefined || typeof pingMs !== 'number' || !Number.isFinite(pingMs)) {
elements.connPing.textContent = '';
elements.connPing.style.color = '';
return;
}
elements.connPing.textContent = `${Math.round(pingMs)}ms`;
if (pingMs < 50) {
elements.connPing.style.color = '#22c55e';
} else if (pingMs < 150) {
elements.connPing.style.color = '#f59e0b';
} else {
elements.connPing.style.color = '#ef4444';
}
}
function updateHistory(history) {
if (!history || !elements.historyList) return;
elements.historyList.innerHTML = '';
@@ -879,7 +981,7 @@ function checkInviteLink() {
if (serverUrl || useCustomServer) {
elements.serverUrl.value = serverUrl;
setServerMode(useCustomServer);
chrome.storage.sync.set({ serverUrl, useCustomServer });
chrome.storage.local.set({ serverUrl, useCustomServer });
}
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
@@ -896,9 +998,9 @@ function setServerMode(custom) {
elements.serverOfficial.classList.toggle('active', !custom);
elements.serverCustom.classList.toggle('active', custom);
elements.serverUrl.style.display = custom ? 'block' : 'none';
chrome.storage.sync.get(['useCustomServer', 'serverUrl'], (data) => {
chrome.storage.local.get(['useCustomServer', 'serverUrl'], (data) => {
if (data.useCustomServer !== custom) {
chrome.storage.sync.set({ useCustomServer: custom });
chrome.storage.local.set({ useCustomServer: custom });
if (!custom || data.serverUrl) {
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
}
@@ -910,46 +1012,84 @@ elements.serverOfficial.addEventListener('click', () => setServerMode(false));
elements.serverCustom.addEventListener('click', () => setServerMode(true));
elements.filterNoise.addEventListener('change', () => {
chrome.storage.sync.set({ filterNoise: elements.filterNoise.checked }, () => {
chrome.storage.local.set({ filterNoise: elements.filterNoise.checked }, () => {
populateTabs();
});
});
elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
elements.browserNotifications.addEventListener('change', () => {
chrome.storage.sync.set({ browserNotifications: elements.browserNotifications.checked });
chrome.storage.local.set({ browserNotifications: elements.browserNotifications.checked });
});
if (elements.autoCopyInvite) {
elements.autoCopyInvite.addEventListener('change', () => {
chrome.storage.sync.set({ autoCopyInvite: elements.autoCopyInvite.checked });
chrome.storage.local.set({ autoCopyInvite: elements.autoCopyInvite.checked });
});
}
if (elements.audioSettingsLink) {
elements.audioSettingsLink.addEventListener('click', async (event) => {
event.preventDefault();
await dismissFeatureHint('audio_processing');
openAudioSettingsPage();
});
}
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync' || !changes.audioSettings) return;
});
elements.forceSyncMode.addEventListener('change', () => {
chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value });
chrome.storage.local.set({ forceSyncMode: elements.forceSyncMode.value });
});
elements.serverUrl.addEventListener('input', () => {
chrome.storage.sync.set({ serverUrl: elements.serverUrl.value });
chrome.storage.local.set({ serverUrl: elements.serverUrl.value });
});
elements.username.addEventListener('change', () => {
chrome.storage.sync.set({ username: elements.username.value });
chrome.storage.local.set({ username: elements.username.value });
});
if (elements.langSelector) {
elements.langSelector.addEventListener('change', async () => {
const selectedLang = elements.langSelector.value;
await chrome.storage.sync.set({ locale: selectedLang });
await chrome.storage.local.set({ locale: selectedLang });
await loadLocale(selectedLang);
translateDOM();
configureFooterLinks();
await updateFeatureHints();
// Re-apply connection and room UI state since translateDOM may overwrite dynamic elements
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
if (chrome.runtime.lastError) return;
if (res) {
localPeerId = res.peerId;
reconnectSlowMode = res.reconnectSlowMode || false;
applyConnectionStatus(res.status);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
await populateTabs(res.peers, res.targetTabId);
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
applyConnectionStatus('disconnected');
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
await populateTabs();
}
});
refreshLogs();
refreshHistory();
populateTabs();
});
}
@@ -958,7 +1098,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' });
@@ -1072,7 +1212,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
@@ -1085,7 +1225,7 @@ 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 = [];
@@ -1354,6 +1494,7 @@ async function refreshLogs() {
}
chrome.runtime.onMessage.addListener((msg) => {
if (!msg) return;
if (msg.type === 'LOG_UPDATE') {
refreshLogs();
if (msg.log && msg.log.type === 'error') {
@@ -1440,8 +1581,10 @@ chrome.runtime.onMessage.addListener((msg) => {
}
if (msg.status === 'connected') {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.peers) updatePeerList(res.peers);
if (res && res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
if (!res) return;
if (res.peers) updatePeerList(res.peers);
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
updatePingDisplay(res.ping);
});
}
if (msg.status === 'disconnected') {
@@ -1460,6 +1603,8 @@ chrome.runtime.onMessage.addListener((msg) => {
}
});
}
} else if (msg.type === 'PING_UPDATE') {
updatePingDisplay(msg.ping);
} else if (msg.type === 'HISTORY_UPDATE') {
updateHistory(msg.history);
} else if (msg.type === 'ROOM_LIST') {
@@ -1474,7 +1619,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.success) {
// Final confirmation of join from background
chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
});
} else {
@@ -1566,6 +1711,7 @@ elements.copyLogs.addEventListener('click', () => {
: '\uD83D\uDD34';
lines.push(`- **Status:** ${emoji} ${st}`);
lines.push(`- **Server:** \`${safe(status.serverUrl, '?')}\``);
lines.push(`- **Ping:** ${status.ping != null ? `${status.ping}ms` : '-'}`);
if (status.roomId) {
lines.push(`- **Room:** \`${status.roomId}\``);
const peers = Array.isArray(status.peers) ? status.peers : [];
@@ -1629,7 +1775,7 @@ elements.copyLogs.addEventListener('click', () => {
// ── Action History (last 20) ──
lines.push('## Action History (last 20)');
if (history && history.length > 0) {
const recent = history.slice(-20);
const recent = history.slice(0, 20).reverse();
lines.push('```');
for (const h of recent) {
if (!h) continue;
@@ -1648,7 +1794,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;
+5 -2
View File
@@ -1,12 +1,12 @@
{
"name": "koalasync",
"version": "2.0.5",
"version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
"version": "2.0.5",
"version": "2.2.2",
"devDependencies": {
"archiver": "^7.0.1",
"esbuild": "^0.28.0",
@@ -1240,6 +1240,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1373,6 +1374,7 @@
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -1895,6 +1897,7 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "2.0.5",
"version": "2.2.2",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
+98
View File
@@ -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');
+138 -2
View File
@@ -55,7 +55,26 @@ console.log(`Auditing i18n locales using ${enKeys.length} baseline keys from en.
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));
@@ -79,11 +98,128 @@ for (const file of localeFiles) {
}
}
// ──────────────────────────────────────
// Verify Chrome _locales/*/messages.json
// ──────────────────────────────────────
console.log('\nVerifying Chrome _locales/messages.json structure...\n');
const chromeLocalesDir = path.join(__dirname, '..', 'extension', '_locales');
// Map custom locale codes to Chrome's underscore format
const chromeLocaleMap = {
'en': 'en', 'de': 'de', 'fr': 'fr', 'es': 'es', 'it': 'it',
'ja': 'ja', 'ko': 'ko', 'nl': 'nl', 'pl': 'pl',
'pt-BR': 'pt_BR', 'pt': 'pt_PT', 'ru': 'ru', 'tr': 'tr'
};
// Read SUPPORTED_LANGUAGES from i18n.js
const i18nContent = fs.readFileSync(i18nPath, 'utf8');
const langMatch = i18nContent.match(/export const SUPPORTED_LANGUAGES = \[(.*?)\];/);
const supportedLangs = langMatch
? langMatch[1].split(',').map(s => s.trim().replace(/['"]/g, ''))
: [];
// Verify default_locale in manifest.base.json
const manifestPath = path.join(__dirname, '..', 'extension', 'manifest.base.json');
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (!manifest.default_locale) {
hasError = true;
console.error('❌ manifest.base.json is missing "default_locale"');
} else if (manifest.default_locale !== 'en') {
hasError = true;
console.error(`❌ manifest.base.json default_locale is "${manifest.default_locale}", expected "en"`);
} else {
console.log('✓ manifest.base.json has default_locale: "en"');
}
} catch (err) {
hasError = true;
console.error('❌ Failed to read manifest.base.json:', err.message);
}
// Verify _locales structure for each supported language
const expectedKeys = ['appName', 'appDesc'];
for (const lang of supportedLangs) {
const chromeLocale = chromeLocaleMap[lang];
if (!chromeLocale) {
hasError = true;
console.error(`❌ No Chrome locale mapping for "${lang}" in chromeLocaleMap`);
continue;
}
const msgPath = path.join(chromeLocalesDir, chromeLocale, 'messages.json');
if (!fs.existsSync(msgPath)) {
hasError = true;
console.error(`❌ Missing _locales/${chromeLocale}/messages.json for language "${lang}"`);
continue;
}
try {
const raw = fs.readFileSync(msgPath, 'utf8');
const messages = JSON.parse(raw);
const keys = Object.keys(messages);
// Detect duplicate top-level keys via regex (JSON.parse silently keeps last value)
const topKeyRe = /^\s{2}"(\w+)"\s*:/gm;
const topSeen = {};
let topDupes = [];
let tm;
while ((tm = topKeyRe.exec(raw)) !== null) {
if (topSeen[tm[1]]) topDupes.push(tm[1]);
topSeen[tm[1]] = true;
}
if (topDupes.length > 0) {
hasError = true;
console.error(`❌ _locales/${chromeLocale}/messages.json has duplicate keys: ${[...new Set(topDupes)].join(', ')}`);
continue;
}
// Validate each entry has a "message" field
for (const key of keys) {
if (!messages[key].message || typeof messages[key].message !== 'string') {
hasError = true;
console.error(`❌ _locales/${chromeLocale}/messages.json: "${key}" is missing a valid "message" field`);
}
}
// Check for missing or extra keys vs expected baseline
const missing = expectedKeys.filter(k => !keys.includes(k));
const extra = keys.filter(k => !expectedKeys.includes(k));
if (missing.length > 0) {
hasError = true;
console.error(`❌ _locales/${chromeLocale}/messages.json missing keys: ${missing.join(', ')}`);
}
if (extra.length > 0) {
console.log(`️ _locales/${chromeLocale}/messages.json has extra keys (ok): ${extra.join(', ')}`);
}
if (missing.length === 0) {
console.log(`✓ _locales/${chromeLocale}/messages.json is valid and complete`);
}
} catch (err) {
hasError = true;
console.error(`❌ Failed to parse _locales/${chromeLocale}/messages.json:`, err.message);
}
}
// Detect orphan _locales directories (no matching supported language)
if (fs.existsSync(chromeLocalesDir)) {
const chromeCodes = supportedLangs.map(l => chromeLocaleMap[l]).filter(Boolean);
const dirs = fs.readdirSync(chromeLocalesDir);
for (const dir of dirs) {
const dirPath = path.join(chromeLocalesDir, dir);
if (fs.statSync(dirPath).isDirectory() && dir !== '.git' && !chromeCodes.includes(dir)) {
hasError = true;
console.error(`❌ Orphan _locales/${dir}/ directory exists but no matching language in SUPPORTED_LANGUAGES`);
}
}
}
console.log('');
if (hasError) {
console.error('❌ Locale consistency check failed! Please fix the errors listed above.');
process.exit(1);
} else {
console.log('🎉 All locale files are perfectly synchronized and consistent!');
console.log('🎉 All locale files (locales/ and _locales/) are valid and consistent!');
process.exit(0);
}
+6 -3
View File
@@ -77,9 +77,12 @@ assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include ave
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'
adminHealth.rateLimits,
{
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }
},
'admin metrics should expose rate-limit tracking and denial counts'
);
console.log('server ops tests passed');
+6 -3
View File
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict';
import {
// Must set env before dynamic import — ESM hoists static imports
process.env.ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
const {
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
HEALTH_RATE_LIMIT_PER_MINUTE,
healthCounts,
@@ -9,9 +12,9 @@ import {
rooms,
startServer,
stopServerForTests
} from '../server/index.js';
} = await import('../server/index.js');
const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
const adminToken = process.env.ADMIN_METRICS_TOKEN;
const baseHeaders = { 'x-forwarded-for': '203.0.113.10' };
function url(path) {
+80
View File
@@ -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);
}
+4 -1
View File
@@ -12,6 +12,7 @@ const checks = [
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']],
@@ -19,6 +20,7 @@ const checks = [
['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') }],
@@ -32,7 +34,8 @@ function runCheck([label, command, args, options = {}]) {
const child = spawn(command, args, {
cwd: options.cwd || repoRoot,
env: { ...process.env, ...(options.env || {}) },
stdio: 'inherit'
stdio: 'inherit',
shell: process.platform === 'win32'
});
child.on('error', reject);
child.on('exit', (code) => {
+8 -5
View File
@@ -1,7 +1,10 @@
PORT=3000
MIN_VERSION=1.0.0
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=25
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=
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"
+3
View File
@@ -10,4 +10,7 @@ RUN cd server && npm install --production
COPY server/ ./server/
WORKDIR /app/server
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "index.js"]
+9 -6
View File
@@ -12,13 +12,16 @@ 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=25
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=
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
@@ -51,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
+72 -7
View File
@@ -82,7 +82,8 @@ app.get('/health', (req, res) => {
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
},
rateLimitDenied
})
));
});
@@ -96,7 +97,7 @@ export const io = new Server(httpServer, {
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
callback(null, true);
} else {
log('CORS', `Rejected origin: ${origin}`);
log('CORS', `Rejected origin: ${(origin || '').replace(/[\r\n]/g, '')}`);
callback(new Error('Not allowed by CORS'));
}
},
@@ -116,6 +117,10 @@ 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);
}
@@ -188,6 +193,15 @@ export const healthCounts = new Map(); // ip -> { count, resetTime }
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
// Actual rate-limit denial counters (incremented only when a request is denied)
const rateLimitDenied = {
connections: 0,
events: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0
};
// Clean up connection counts and event counts to prevent memory leak
const rateLimitCleanupInterval = setInterval(() => {
const now = Date.now();
@@ -224,7 +238,9 @@ function checkConnectionRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
connectionCounts.set(ip, entry);
return entry.count <= 10;
if (entry.count <= 10) return true;
rateLimitDenied.connections++;
return false;
}
function checkEventRate(socketId) {
@@ -233,7 +249,9 @@ function checkEventRate(socketId) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
entry.count++;
eventCounts.set(socketId, entry);
return entry.count <= 30;
if (entry.count <= 30) return true;
rateLimitDenied.events++;
return false;
}
function checkHealthRate(ip) {
@@ -242,7 +260,9 @@ function checkHealthRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
healthCounts.set(ip, entry);
return entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE;
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.health++;
return false;
}
function checkAdminMetricsAuthRate(ip) {
@@ -251,7 +271,9 @@ function checkAdminMetricsAuthRate(ip) {
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;
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.adminMetricsAuth++;
return false;
}
/**
@@ -581,7 +603,7 @@ io.on('connection', (socket) => {
socket.to(mapping.roomId).emit(eventName, relayPayload);
// --- Side-effects: Server-side Episode Lobby Tracking ---
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) {
room.activeLobby = {
expectedTitle: relayPayload.expectedTitle,
initiatorPeerId: mapping.peerId,
@@ -609,6 +631,7 @@ io.on('connection', (socket) => {
return;
}
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
rateLimitDenied.roomList++;
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
return;
}
@@ -653,6 +676,48 @@ io.on('connection', (socket) => {
}
});
socket.on(EVENTS.PING, (data) => {
if (!checkEventRate(socket.id)) {
socket.disconnect(true);
return;
}
if (!data || typeof data.t !== 'number' || !Number.isFinite(data.t)) return;
if (typeof data.target === 'string' && data.target.length > 0) {
const targetSocketId = peerToSocket.get(data.target);
const senderMapping = socketToRoom.get(socket.id);
if (targetSocketId && senderMapping && data.target !== senderMapping.peerId) {
const targetMapping = socketToRoom.get(targetSocketId);
if (targetMapping && targetMapping.roomId === senderMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.PING, { t: data.t, sender: senderMapping.peerId });
return;
}
}
}
socket.emit(EVENTS.PONG, { t: data.t });
});
socket.on(EVENTS.PONG, (data) => {
if (!checkEventRate(socket.id)) {
socket.disconnect(true);
return;
}
if (!data || typeof data.target !== 'string' || data.target.length === 0) return;
if (typeof data.t !== 'number' || !Number.isFinite(data.t)) return;
const senderMapping = socketToRoom.get(socket.id);
if (!senderMapping || data.target === senderMapping.peerId) return;
const targetSocketId = peerToSocket.get(data.target);
if (!targetSocketId) return;
const targetMapping = socketToRoom.get(targetSocketId);
if (targetMapping && targetMapping.roomId === senderMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.PONG, { t: data.t });
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
roomListCooldowns.delete(socket.id);
+18 -8
View File
@@ -48,7 +48,8 @@ export function buildHealthPayload({
now = Date.now(),
uptime = 0,
memoryUsage = () => process.memoryUsage(),
rateLimitSizes = {}
rateLimitSizes = {},
rateLimitDenied = {}
}) {
const payload = {
status: 'ok',
@@ -75,13 +76,22 @@ export function buildHealthPayload({
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
rateLimits: {
trackedClients: {
connections: rateLimitSizes.connections || 0,
events: rateLimitSizes.events || 0,
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
},
denied: {
connections: rateLimitDenied.connections || 0,
events: rateLimitDenied.events || 0,
health: rateLimitDenied.health || 0,
adminMetricsAuth: rateLimitDenied.adminMetricsAuth || 0,
roomList: rateLimitDenied.roomList || 0
}
},
memory: {
rss: mem.rss,
+1
View File
@@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
+19 -1
View File
@@ -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
@@ -37,7 +51,11 @@ export const EVENTS = {
// Episode Auto-Sync
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel" // Broadcast: cancel active lobby and resume
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
// Ping / Latency
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
PONG: "pong" // server responds with same { t } for client RTT calculation
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
+3 -3
View File
@@ -21,7 +21,7 @@ export const USERNAME_ADJECTIVES = [
'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden',
'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson',
'Turbo', 'Zen', 'Pixel', 'Cyber', 'Solar', 'Lunar', 'Astro', 'Hyper',
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Pixel', 'Warp', 'Frost',
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Nitro', 'Warp', 'Frost',
];
export const USERNAME_NOUNS = [
@@ -39,7 +39,7 @@ export const USERNAME_NOUNS = [
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Opossum', 'Squid',
'Jellyfish', 'Turtle',
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
@@ -102,7 +102,7 @@ export const ANIMAL_EMOJI_MAP = {
'mammoth': '🦣',
'meerkat': '🦦',
'octopus': '🐙',
'opposum': '🐭',
'opossum': '🐭',
'ostrich': '🐦',
'panther': '🐆',
'pelican': '🦩',
+1 -1
View File
@@ -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 {
+26 -12
View File
@@ -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)
+83 -12
View File
@@ -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]');
@@ -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
@@ -618,7 +689,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!isInstalled) {
btn.classList.add('install-breathe');
btn.style.cursor = 'pointer';
btn.onclick = () => window.open('https://addons.mozilla.org/de/firefox/addon/koalasync/', '_blank');
btn.onclick = () => window.open('https://addons.mozilla.org/de/firefox/addon/koalasync/', '_blank', 'noopener');
}
});
illusChrome.forEach(btn => {
@@ -631,7 +702,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!isInstalled) {
btn.classList.add('install-breathe');
btn.style.cursor = 'pointer';
btn.onclick = () => window.open('https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc', '_blank');
btn.onclick = () => window.open('https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc', '_blank', 'noopener');
}
});
illusFirefox.forEach(btn => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More