Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f0c2b265 | |||
| af59b4c64c | |||
| af8184420c | |||
| 45e1e3defe | |||
| e67b0c564d | |||
| f54a38b656 | |||
| 5216fde641 | |||
| 39fe2cbd11 | |||
| 03d5dbda66 | |||
| 0c0cbbc090 | |||
| 0f4ad39fb4 | |||
| cfbb070c57 | |||
| 0caf19cf18 | |||
| ff31f56911 | |||
| 54d03df0af | |||
| 4f1016fa87 | |||
| 3e7afd7592 | |||
| 6c6d4dc2a2 | |||
| 498d5bd91b | |||
| bfd1177a14 | |||
| 15f4f5a12e | |||
| fa39be7d40 | |||
| 5fd00f0bda | |||
| dca9c6ed07 | |||
| d8634744ea | |||
| 2031f76bee | |||
| 3757308117 | |||
| e4fae56509 | |||
| 766bc1760c | |||
| 4949b04d07 | |||
| 6ba81d66a4 | |||
| d5ed1083bc | |||
| 78d576c7f6 | |||
| 08ce689f4c | |||
| bf48d1889b | |||
| 39788e5bd9 | |||
| c134e1bfae | |||
| cad4b4a6db | |||
| 9e2abadf5a | |||
| f5db39b77b | |||
| 85d05b85c9 | |||
| 6b438f2d37 | |||
| 7bba36c505 | |||
| f93937f5f3 | |||
| da12dc07a7 | |||
| 198064b720 | |||
| def0ad6ded | |||
| 026bbc65b2 | |||
| 854be35474 | |||
| 3506eb0331 | |||
| 0a34d804fb | |||
| a1e882cfa7 | |||
| c88f6fb121 | |||
| fe047dea2e | |||
| 464f5f466a | |||
| 9f00645f58 | |||
| f95095f2cd | |||
| be91d07c56 | |||
| a6bc7cae48 | |||
| d0c4b3740d | |||
| 67a7f6e663 | |||
| 7499eafe4e | |||
| 3fe8ca18e4 | |||
| f0ccc0c082 | |||
| d59fc4777d | |||
| 3ad2459558 | |||
| 839f4d0761 | |||
| 8838bf20b7 | |||
| 9d849a2996 | |||
| 6d5661ea45 | |||
| 886550408a | |||
| 28694c22bb | |||
| a2a56f2b17 | |||
| 742876e415 | |||
| bcd956a8db | |||
| 1e329c0c52 |
@@ -0,0 +1,2 @@
|
||||
# These are supported funding model platforms
|
||||
ko_fi: koaladev
|
||||
@@ -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:
|
||||
|
||||
@@ -40,5 +40,8 @@ coverage/
|
||||
# the root 'shared/' remains the Single Source of Truth.
|
||||
extension/shared/
|
||||
|
||||
# Auto-generated website build output
|
||||
website/www/
|
||||
|
||||
# Temporary scratch files
|
||||
scratch/
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
@@ -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.7 Release!</b> — See what's changed</a></p>
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.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>
|
||||
|
||||
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,57 @@
|
||||
KoalaSync: Private Watch Parties for Emby, Jellyfin, Plex, Netflix & YouTube
|
||||
|
||||
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube — KoalaSync is designed for smooth, browser-based watch parties.
|
||||
|
||||
|
||||
✨ CORE FEATURES
|
||||
No account required. No tracking. Just create a room, invite your friends, and start watching together.
|
||||
|
||||
• Real-Time Video Sync: Play, pause, seek, and watch together with fast synchronized playback across everyone in your room.
|
||||
• No Account Needed: Create a room and share the invite link. No emails, no passwords, no sign-ups. Pick a nickname or let KoalaSync generate one for you.
|
||||
• Works Almost Everywhere: If the website uses a standard HTML5 video player, KoalaSync can usually sync it. Perfect for streaming sites, self-hosted media servers, and other websites.
|
||||
• Smart Binge-Watching: When a new episode loads, KoalaSync automatically pauses the lobby until everyone is ready. No spoilers, no one left behind.
|
||||
• Smart Audio Compressor: Tired of quiet dialogue and suddenly loud action scenes? Balance whispering, explosions, and music with a single click while you watch.
|
||||
• One-Click Invites: Send a smart invite link to your friends. When they open it, KoalaSync automatically configures the room so they can join instantly.
|
||||
• 13 Languages: Enjoy a native experience with a fully translated user interface.
|
||||
|
||||
|
||||
|
||||
🛡️ PRIVACY & SECURITY
|
||||
KoalaSync is built for private watch parties without unnecessary data collection.
|
||||
|
||||
• No Tracking: Zero analytics, zero telemetry, and absolutely no behavior profiling.
|
||||
• Anonymous by Design: No accounts needed. Rooms can be joined with a simple nickname.
|
||||
• Ready Out of the Box: Install KoalaSync and start watching immediately using the official public relay server. No technical setup required.
|
||||
• RAM-Only Public Server: The official relay server operates entirely in volatile RAM. No databases, no stored watch history, no persistent room data. Room data exists only temporarily and disappears when the room closes.
|
||||
• Self-Hostable: Want full control? You can run your own private KoalaSync relay server via Docker in seconds. Self-hosting is optional and never required.
|
||||
|
||||
|
||||
|
||||
🚀 HOW IT WORKS
|
||||
1. Install KoalaSync.
|
||||
2. Click "Create Room" to start a private watch party.
|
||||
3. Share the invite link with your friends.
|
||||
4. Open your favorite streaming site or media server.
|
||||
5. Select the active video tab.
|
||||
6. Press play — everyone stays perfectly in sync.
|
||||
|
||||
|
||||
|
||||
⚙️ UNDER THE HOOD
|
||||
KoalaSync is lightweight, transparent, and built with privacy in mind.
|
||||
|
||||
• 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
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
@@ -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.
|
||||
|
||||
@@ -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, 50–150ms yellow, >150ms red). No ping value is shown when disconnected or if the server does not respond within 5 seconds.
|
||||
- **Peer Ping Response (Future-Proof)**: The extension can now respond to incoming `PING { t, sender }` events from other peers by sending back `PONG { t, target: sender }`. The relay server forwards `PING` to the target peer and routes `PONG` back to the original sender. Both client and server validate that peers are in the same room before forwarding/routing. Peer-to-peer ping initiation will be activated in a future extension update without requiring a server restart.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.0] — 2026-06-08
|
||||
|
||||
### Added
|
||||
- **Web Audio API Compressor**: Built-in audio dynamic range compression with four presets (Recommended, Dynamic Range, Vocal Enhancement, Smooth) and fully customizable sliders (threshold, ratio, knee, attack, release). Uses dry/wet crossfade (40ms linear ramp) to avoid clicks. Configured via the new Audio Options page accessible from the Settings tab.
|
||||
- **Audio Options Page** (`audio-options.html`): Dedicated settings page with master toggle, compressor preset selector, real-time custom sliders, and equalizer placeholder. Dark theme matching the popup design.
|
||||
- **Feature Hint System**: Generic `dismissedHints` array in sync storage for announcing new features. First hint highlights the Audio Options entry in Settings. Extensible for future features.
|
||||
|
||||
### Changed
|
||||
- **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,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)
|
||||
@@ -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.
|
||||
|
||||
@@ -4,19 +4,19 @@ This document tracks which streaming platforms and media servers have been teste
|
||||
|
||||
| Service | Sync Works | Media Title | Episode Auto-Sync | Notes |
|
||||
|---------|:----------:|:-----------:|:-----------------:|-------|
|
||||
| **YouTube** | ✅ Full | ✅ Full | ❌ | Best-in-class support. Native player API, reliable title detection. |
|
||||
| **Twitch** | ✅ Full | ✅ Full | ❌ | Live-only platform. Tested on regular streams. |
|
||||
| **Netflix** | ✅ Full | ⚠️ Hidden | ⚠️ Manual | Sync works perfectly, but DRM prevents media title detection. Episode transitions may require manual lobby. |
|
||||
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
|
||||
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
|
||||
| **Plex** | Not tested | Not tested | Not tested | Community reports indicate compatibility via HTML5 player mode. |
|
||||
| **Disney+** | Not tested | Not tested | Not tested | Widevine DRM may restrict title detection similar to Netflix. |
|
||||
| **Prime Video** | ⚠️ Partial | ⚠️ Partial | ❌ | Video elements detected (2 on page, picks larger one). Playback state + time readable. However, the preview/trailer video may be selected instead of the main content. Play/Pause commands may not reach the correct player. Title detection from MediaSession API may work for some content. |
|
||||
| **YouTube** | ✅ Full | ✅ Full | ❌ | Individual videos, not episodes — no episode auto-sync. |
|
||||
| **Twitch** | ✅ Full | ✅ Full | ❌ | Individual streams/VODs, not episodes — no episode auto-sync. |
|
||||
| **Netflix** | ✅ Full | ❌ | ❌ | No media title exposed. |
|
||||
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Best-in-class support. |
|
||||
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | — |
|
||||
| **Plex** | Not tested | Not tested | Not tested | — |
|
||||
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | Series title only (e.g. "The Simpsons"), no episode info. |
|
||||
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — |
|
||||
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
|
||||
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
|
||||
| **Vimeo** | Not tested | Not tested | Not tested | — |
|
||||
| **Dailymotion** | Not tested | Not tested | Not tested | — |
|
||||
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | German public broadcasters. HTML5 players expected to work. |
|
||||
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | — |
|
||||
|
||||
## Legend
|
||||
|
||||
@@ -39,7 +39,7 @@ Tested a service that's not listed? Found different behavior than documented?
|
||||
KoalaSync works on any website with a **standard HTML5 `<video>` element** that allows script injection.
|
||||
|
||||
Limited functionality on certain platforms is typically caused by:
|
||||
- **DRM/Copy Protection** (e.g., Widevine on Netflix, Disney+) which restricts access to media metadata like title and playback state
|
||||
- **DRM/Copy Protection** (e.g., Widevine on Netflix) which restricts access to media metadata like title and playback state
|
||||
- **Shadow DOM encapsulation** that hides video elements from content scripts
|
||||
- **Strict Content Security Policies** (CSP) that block script injection
|
||||
|
||||
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincroniza la reproducción de video en YouTube, Netflix, Emby, Jellyfin y cualquier sitio HTML5 en tiempo real con amigos."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube, Netflix, Emby, Jellyfin 및 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--panel: #172033;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--text: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #334155;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--accent-hover);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.muted-panel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.master-toggle,
|
||||
.inline-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.master-toggle,
|
||||
.inline-toggle {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preset-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.preset-card {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.preset-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.preset-card:has(input:checked) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.preset-card input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.custom-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: var(--radius);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(160px, 1fr) 78px 30px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.control-row label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input[type="number"]:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #334155;
|
||||
transition: .3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #94a3b8;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.page-header,
|
||||
.section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
grid-template-columns: 1fr 74px 28px;
|
||||
}
|
||||
|
||||
.control-row label {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>KoalaSync Audio Settings</title>
|
||||
<link rel="stylesheet" href="audio-options.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page-shell">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a id="backLink" href="#" class="back-link" data-i18n="AUDIO_BACK">← Back</a>
|
||||
<p class="eyebrow">KoalaSync</p>
|
||||
<h1 data-i18n="AUDIO_PAGE_TITLE">Audio Settings</h1>
|
||||
</div>
|
||||
<label class="master-toggle">
|
||||
<span data-i18n="AUDIO_MASTER_TOGGLE">Audio Processing</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="audioEnabled">
|
||||
<span class="slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-heading">
|
||||
<h2 data-i18n="AUDIO_COMPRESSOR">Compressor</h2>
|
||||
<label class="inline-toggle">
|
||||
<span data-i18n="AUDIO_COMPRESSOR_ENABLE">Enabled</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="compressorEnabled">
|
||||
<span class="slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="preset-group" role="radiogroup" aria-label="Compressor preset">
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="recommended">
|
||||
<span data-i18n="AUDIO_PRESET_RECOMMENDED">Recommended</span>
|
||||
</label>
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="dynamicRange">
|
||||
<span data-i18n="AUDIO_PRESET_DYNAMIC_RANGE">Dynamic Range</span>
|
||||
</label>
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="vocalEnhancement">
|
||||
<span data-i18n="AUDIO_PRESET_VOCAL_ENHANCEMENT">Vocal Enhancement</span>
|
||||
</label>
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="smooth">
|
||||
<span data-i18n="AUDIO_PRESET_SMOOTH">Smooth</span>
|
||||
</label>
|
||||
<label class="preset-card">
|
||||
<input type="radio" name="preset" value="custom">
|
||||
<span data-i18n="AUDIO_PRESET_CUSTOM">Custom</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="custom-grid" id="customControls">
|
||||
<div class="control-row" data-param="threshold">
|
||||
<label data-i18n="AUDIO_PARAM_THRESHOLD">Threshold</label>
|
||||
<input type="range" min="-60" max="0" step="1">
|
||||
<input type="number" min="-60" max="0" step="1">
|
||||
<span class="unit">dB</span>
|
||||
</div>
|
||||
<div class="control-row" data-param="knee">
|
||||
<label data-i18n="AUDIO_PARAM_KNEE">Knee</label>
|
||||
<input type="range" min="0" max="40" step="1">
|
||||
<input type="number" min="0" max="40" step="1">
|
||||
<span class="unit">dB</span>
|
||||
</div>
|
||||
<div class="control-row" data-param="ratio">
|
||||
<label data-i18n="AUDIO_PARAM_RATIO">Ratio</label>
|
||||
<input type="range" min="1" max="20" step="0.5">
|
||||
<input type="number" min="1" max="20" step="0.5">
|
||||
<span class="unit">:1</span>
|
||||
</div>
|
||||
<div class="control-row" data-param="attack">
|
||||
<label data-i18n="AUDIO_PARAM_ATTACK">Attack</label>
|
||||
<input type="range" min="0" max="1" step="0.001">
|
||||
<input type="number" min="0" max="1000" step="1" data-ms-input="true">
|
||||
<span class="unit">ms</span>
|
||||
</div>
|
||||
<div class="control-row" data-param="release">
|
||||
<label data-i18n="AUDIO_PARAM_RELEASE">Release</label>
|
||||
<input type="range" min="0" max="1" step="0.005">
|
||||
<input type="number" min="0" max="1000" step="5" data-ms-input="true">
|
||||
<span class="unit">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel muted-panel">
|
||||
<div class="section-heading">
|
||||
<h2 data-i18n="AUDIO_EQUALIZER">Equalizer</h2>
|
||||
</div>
|
||||
<p data-i18n="AUDIO_COMING_SOON">Coming soon</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="audio-options.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,196 @@
|
||||
import { loadLocale, translateDOM, getSystemLanguage } from './i18n.js';
|
||||
|
||||
const PRESETS = {
|
||||
recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 },
|
||||
dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 },
|
||||
vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 },
|
||||
smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 },
|
||||
custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 }
|
||||
};
|
||||
|
||||
const DEFAULT_AUDIO_SETTINGS = {
|
||||
enabled: false,
|
||||
compressor: {
|
||||
enabled: false,
|
||||
preset: 'recommended',
|
||||
customParams: { ...PRESETS.custom }
|
||||
}
|
||||
};
|
||||
const PARAM_LIMITS = {
|
||||
threshold: { min: -60, max: 0 },
|
||||
knee: { min: 0, max: 40 },
|
||||
ratio: { min: 1, max: 20 },
|
||||
attack: { min: 0, max: 1 },
|
||||
release: { min: 0, max: 1 }
|
||||
};
|
||||
|
||||
const elements = {
|
||||
audioEnabled: document.getElementById('audioEnabled'),
|
||||
compressorEnabled: document.getElementById('compressorEnabled'),
|
||||
presetInputs: Array.from(document.querySelectorAll('input[name="preset"]')),
|
||||
controlRows: Array.from(document.querySelectorAll('.control-row')),
|
||||
backLink: document.getElementById('backLink')
|
||||
};
|
||||
|
||||
let saveTimer = null;
|
||||
let isRendering = false;
|
||||
|
||||
function cloneDefaultSettings() {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_AUDIO_SETTINGS));
|
||||
}
|
||||
|
||||
let currentSettings = cloneDefaultSettings();
|
||||
|
||||
function mergeAudioSettings(settings = {}) {
|
||||
const safeSettings = settings && typeof settings === 'object' ? settings : {};
|
||||
const defaults = cloneDefaultSettings();
|
||||
return {
|
||||
...defaults,
|
||||
...safeSettings,
|
||||
compressor: {
|
||||
...defaults.compressor,
|
||||
...(safeSettings.compressor || {}),
|
||||
customParams: {
|
||||
...defaults.compressor.customParams,
|
||||
...(safeSettings.compressor?.customParams || {})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function debounceSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
chrome.storage.local.set({ audioSettings: currentSettings });
|
||||
}, 40);
|
||||
}
|
||||
|
||||
function getParamValue(param, value, isMsInput = false) {
|
||||
const parsed = Number(value);
|
||||
const candidate = Number.isFinite(parsed)
|
||||
? (isMsInput ? parsed / 1000 : parsed)
|
||||
: currentSettings.compressor.customParams[param];
|
||||
const limits = PARAM_LIMITS[param];
|
||||
if (!limits) return candidate;
|
||||
return Math.min(limits.max, Math.max(limits.min, candidate));
|
||||
}
|
||||
|
||||
function formatNumber(value, param, isMsInput = false) {
|
||||
if (isMsInput) return Math.round(value * 1000);
|
||||
if (param === 'ratio') return Number(value).toFixed(1).replace(/\.0$/, '');
|
||||
return value;
|
||||
}
|
||||
|
||||
function render() {
|
||||
isRendering = true;
|
||||
elements.audioEnabled.checked = currentSettings.enabled === true;
|
||||
elements.compressorEnabled.checked = currentSettings.compressor.enabled === true;
|
||||
|
||||
const selectedPreset = currentSettings.compressor.preset || 'recommended';
|
||||
elements.presetInputs.forEach(input => {
|
||||
input.checked = input.value === selectedPreset;
|
||||
});
|
||||
|
||||
const params = selectedPreset === 'custom'
|
||||
? currentSettings.compressor.customParams
|
||||
: PRESETS[selectedPreset] || PRESETS.recommended;
|
||||
|
||||
elements.controlRows.forEach(row => {
|
||||
const param = row.dataset.param;
|
||||
const range = row.querySelector('input[type="range"]');
|
||||
const number = row.querySelector('input[type="number"]');
|
||||
const value = params[param];
|
||||
range.value = value;
|
||||
number.value = formatNumber(value, param, number.dataset.msInput === 'true');
|
||||
});
|
||||
isRendering = false;
|
||||
}
|
||||
|
||||
function setPreset(preset) {
|
||||
currentSettings.compressor.preset = preset;
|
||||
if (preset === 'custom') {
|
||||
currentSettings.compressor.customParams = {
|
||||
...PRESETS.custom,
|
||||
...currentSettings.compressor.customParams
|
||||
};
|
||||
}
|
||||
render();
|
||||
debounceSave();
|
||||
}
|
||||
|
||||
function setCustomParam(param, value) {
|
||||
currentSettings.compressor.preset = 'custom';
|
||||
currentSettings.compressor.customParams[param] = getParamValue(param, value);
|
||||
render();
|
||||
debounceSave();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
let audioData = (await chrome.storage.local.get(['audioSettings'])).audioSettings;
|
||||
const syncData = await chrome.storage.sync.get(['audioSettings', 'locale']);
|
||||
if (!audioData && syncData.audioSettings) {
|
||||
audioData = syncData.audioSettings;
|
||||
await chrome.storage.local.set({ audioSettings: audioData });
|
||||
}
|
||||
const lang = syncData.locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
translateDOM();
|
||||
|
||||
currentSettings = mergeAudioSettings(audioData);
|
||||
render();
|
||||
}
|
||||
|
||||
elements.audioEnabled.addEventListener('change', () => {
|
||||
currentSettings.enabled = elements.audioEnabled.checked;
|
||||
if (currentSettings.enabled && !currentSettings.compressor.enabled) {
|
||||
currentSettings.compressor.enabled = true;
|
||||
}
|
||||
render();
|
||||
debounceSave();
|
||||
});
|
||||
|
||||
elements.compressorEnabled.addEventListener('change', () => {
|
||||
currentSettings.compressor.enabled = elements.compressorEnabled.checked;
|
||||
if (currentSettings.compressor.enabled) currentSettings.enabled = true;
|
||||
render();
|
||||
debounceSave();
|
||||
});
|
||||
|
||||
elements.presetInputs.forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
if (input.checked) setPreset(input.value);
|
||||
});
|
||||
});
|
||||
|
||||
elements.controlRows.forEach(row => {
|
||||
const param = row.dataset.param;
|
||||
const range = row.querySelector('input[type="range"]');
|
||||
const number = row.querySelector('input[type="number"]');
|
||||
|
||||
range.addEventListener('input', () => {
|
||||
if (isRendering) return;
|
||||
setCustomParam(param, getParamValue(param, range.value));
|
||||
});
|
||||
|
||||
number.addEventListener('input', () => {
|
||||
if (isRendering) return;
|
||||
setCustomParam(param, getParamValue(param, number.value, number.dataset.msInput === 'true'));
|
||||
});
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
currentSettings = mergeAudioSettings(changes.audioSettings.newValue);
|
||||
render();
|
||||
});
|
||||
|
||||
if (elements.backLink) {
|
||||
elements.backLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
|
||||
init().catch(err => {
|
||||
console.error('[AudioOptions] Failed to initialize:', err);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, 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();
|
||||
@@ -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') {
|
||||
@@ -1355,9 +1436,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
serverUrl: currentServerUrl,
|
||||
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(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ document.documentElement.dataset.koalasyncInstalled = 'true';
|
||||
|
||||
// 2. Listen for Join Requests from the Website
|
||||
window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
|
||||
if (!e || !e.detail) return;
|
||||
const { roomId, password, useCustomServer, serverUrl } = e.detail;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'WEB_JOIN_REQUEST',
|
||||
@@ -17,11 +18,12 @@ window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
|
||||
password,
|
||||
useCustomServer,
|
||||
serverUrl
|
||||
});
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// 3. Listen for Status Updates from the Extension and relay to Website
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (!msg) return;
|
||||
if (msg.type === 'JOIN_STATUS') {
|
||||
const detail = { success: msg.success, message: msg.message };
|
||||
// Firefox MV3 content scripts run in an isolated world. When dispatching
|
||||
|
||||
@@ -62,13 +62,35 @@
|
||||
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
let lobbyPollTimer = null;
|
||||
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
|
||||
let _audioSettings = null;
|
||||
let _audioProcessingAllowed = true;
|
||||
|
||||
// Cache the autoSyncNextEpisode setting
|
||||
chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => {
|
||||
_autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled
|
||||
chrome.storage.local.get(['autoSyncNextEpisode', 'audioSettings'], (data) => {
|
||||
if (data.autoSyncNextEpisode === undefined || data.audioSettings === undefined) {
|
||||
chrome.storage.sync.get(['autoSyncNextEpisode', 'audioSettings'], (syncData) => {
|
||||
const migrate = {};
|
||||
if (data.autoSyncNextEpisode === undefined && syncData.autoSyncNextEpisode !== undefined) {
|
||||
migrate.autoSyncNextEpisode = syncData.autoSyncNextEpisode;
|
||||
}
|
||||
if (data.audioSettings === undefined && syncData.audioSettings !== undefined) {
|
||||
migrate.audioSettings = syncData.audioSettings;
|
||||
}
|
||||
if (Object.keys(migrate).length) chrome.storage.local.set(migrate);
|
||||
_autoSyncEnabled = syncData.autoSyncNextEpisode !== false;
|
||||
_audioSettings = mergeAudioSettings(syncData.audioSettings);
|
||||
const v = findVideo();
|
||||
if (v && _audioProcessingAllowed) applyAudioSettings(v, _audioSettings);
|
||||
});
|
||||
return;
|
||||
}
|
||||
_autoSyncEnabled = data.autoSyncNextEpisode !== false;
|
||||
_audioSettings = mergeAudioSettings(data.audioSettings);
|
||||
const video = findVideo();
|
||||
if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
|
||||
});
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'sync' && changes.autoSyncNextEpisode) {
|
||||
if (area === 'local' && changes.autoSyncNextEpisode) {
|
||||
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// extension/i18n.js
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'];
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
let activeDictionary = {};
|
||||
const dictionaryCache = {};
|
||||
let currentLanguage = null;
|
||||
|
||||
/**
|
||||
* Resolves, loads, and merges the target language with the English baseline fallback.
|
||||
@@ -11,13 +13,30 @@ let activeDictionary = {};
|
||||
export async function loadLocale(langCode) {
|
||||
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
|
||||
|
||||
if (currentLanguage === resolvedLang && Object.keys(activeDictionary).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dictionaryCache[resolvedLang]) {
|
||||
activeDictionary = dictionaryCache[resolvedLang];
|
||||
currentLanguage = resolvedLang;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load Baseline English
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
const enDict = await enResponse.json();
|
||||
let enDict;
|
||||
if (dictionaryCache[DEFAULT_LANGUAGE]) {
|
||||
enDict = dictionaryCache[DEFAULT_LANGUAGE];
|
||||
} else {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
enDict = await enResponse.json();
|
||||
dictionaryCache[DEFAULT_LANGUAGE] = enDict;
|
||||
}
|
||||
|
||||
if (resolvedLang === DEFAULT_LANGUAGE) {
|
||||
activeDictionary = enDict;
|
||||
currentLanguage = resolvedLang;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,15 +45,27 @@ export async function loadLocale(langCode) {
|
||||
const targetDict = await targetResponse.json();
|
||||
|
||||
// Airtight Fallback Merge: target overrides en, missing elements fallback to en
|
||||
activeDictionary = Object.assign({}, enDict, targetDict);
|
||||
const mergedDict = Object.assign({}, enDict, targetDict);
|
||||
dictionaryCache[resolvedLang] = mergedDict;
|
||||
activeDictionary = mergedDict;
|
||||
currentLanguage = resolvedLang;
|
||||
} catch (err) {
|
||||
console.error('[i18n] Failed to load locale. Defaulting to English:', err);
|
||||
// Fallback directly to static English if fetching fails
|
||||
try {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
activeDictionary = await enResponse.json();
|
||||
let enDict;
|
||||
if (dictionaryCache[DEFAULT_LANGUAGE]) {
|
||||
enDict = dictionaryCache[DEFAULT_LANGUAGE];
|
||||
} else {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
enDict = await enResponse.json();
|
||||
dictionaryCache[DEFAULT_LANGUAGE] = enDict;
|
||||
}
|
||||
activeDictionary = enDict;
|
||||
currentLanguage = DEFAULT_LANGUAGE;
|
||||
} catch (_) {
|
||||
activeDictionary = {};
|
||||
currentLanguage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +77,7 @@ export async function loadLocale(langCode) {
|
||||
* @returns {string} Translated string or the key itself
|
||||
*/
|
||||
export function getMessage(key, placeholders = null) {
|
||||
let msg = activeDictionary[key] || key;
|
||||
let msg = activeDictionary[key] !== undefined ? String(activeDictionary[key]) : key;
|
||||
if (placeholders && typeof placeholders === 'object') {
|
||||
for (const [k, v] of Object.entries(placeholders)) {
|
||||
msg = msg.replace(new RegExp(`{${k}}`, 'g'), v);
|
||||
@@ -87,3 +118,19 @@ export function translateDOM() {
|
||||
el.setAttribute('placeholder', getMessage(key));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and maps the user's system language to the best supported locale.
|
||||
* @returns {string} Supported language code
|
||||
*/
|
||||
export function getSystemLanguage() {
|
||||
const uiLang = (typeof chrome !== 'undefined' && chrome.i18n && chrome.i18n.getUILanguage)
|
||||
? chrome.i18n.getUILanguage()
|
||||
: '';
|
||||
const fullLang = (navigator.language || uiLang || '').toLowerCase();
|
||||
if (fullLang.startsWith('pt-br')) {
|
||||
return 'pt-BR';
|
||||
}
|
||||
const baseLang = fullLang.split('-')[0];
|
||||
return SUPPORTED_LANGUAGES.includes(baseLang) ? baseLang : DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 852 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 10 KiB |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Stanza",
|
||||
"TAB_ROOM_TOOLTIP": "Impostazioni della stanza e connessione",
|
||||
"TAB_SYNC": "Sincronizza",
|
||||
"TAB_SYNC_TOOLTIP": "Controlli di sincronizzazione video e azioni remote",
|
||||
"TAB_SETTINGS": "Impostazioni",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferenze dell'estensione",
|
||||
"TAB_STATUS": "Stato",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnostica avanzata e log",
|
||||
"BTN_CREATE_ROOM": "+ Crea Nuova Stanza",
|
||||
"MANUAL_CONNECT_HEADER": "Connessione Manuale / Avanzata",
|
||||
"LABEL_SERVER": "Server",
|
||||
"BTN_SERVER_OFFICIAL": "Ufficiale",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usa il server ufficiale affidabile",
|
||||
"BTN_SERVER_CUSTOM": "Personalizzato",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Connettiti al tuo server ospitato autonomamente",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://tuo-server:3000",
|
||||
"LABEL_ROOM_ID": "ID Stanza",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "L'identificatore univoco per la tua stanza di sincronizzazione",
|
||||
"PLACEHOLDER_ROOM_ID": "Inserisci ID Stanza",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID univoco della stanza a cui vuoi unirti",
|
||||
"LABEL_PASSWORD": "Password (Opzionale)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Password opzionale per limitare l'accesso alla stanza",
|
||||
"PLACEHOLDER_PASSWORD": "Password della stanza (opzionale)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password per la stanza (lascia vuoto se nessuna)",
|
||||
"BTN_JOIN_ROOM": "Entra / Crea Stanza",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Connettiti alla stanza",
|
||||
"LABEL_PUBLIC_ROOMS": "Stanze Pubbliche",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubblicamente disponibili su questo server",
|
||||
"BTN_REFRESH": "AGGIORNA",
|
||||
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze pubbliche",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Aggiornamento...",
|
||||
"BTN_REFRESH_COOLDOWN": "ATTENDI {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "L'aggiornamento dell'elenco è in pausa. Riprova tra {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento stanze pubbliche. Prossimo aggiornamento disponibile tra {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Stanza Attiva",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei attualmente connesso",
|
||||
"ACTIVE_ROOM_NONE": "NESSUNA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Server Ufficiale",
|
||||
"LABEL_INVITE_LINK": "Link di Invito",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Condividi questo link con gli amici per farli entrare",
|
||||
"LABEL_PEERS_IN_ROOM": "Partecipanti nella Stanza",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Altri utenti attualmente connessi a questa stanza",
|
||||
"NO_PEERS_CONNECTED": "Nessun partecipante connesso",
|
||||
"BTN_LEAVE_ROOM": "Lascia Stanza",
|
||||
"LABEL_SELECT_VIDEO": "Seleziona Video",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Scegli la scheda del browser contenente il video da sincronizzare",
|
||||
"OPTION_SELECT_TAB": "-- Seleziona una Scheda --",
|
||||
"LABEL_REMOTE_CONTROL": "Controllo Remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link di Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copia Link di Invito",
|
||||
"BTN_PLAY": "▶ Riproduci",
|
||||
"BTN_PLAY_TOOLTIP": "Invia un comando di riproduzione a tutti",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Invia un comando di pausa a tutti",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione di tutti gli utenti",
|
||||
"OPTION_JUMP_TO_OTHERS": "Salto dagli Altri",
|
||||
"OPTION_JUMP_TO_ME": "Salto da Me",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Scegli il target di sincronizzazione",
|
||||
"LABEL_LAST_ACTIVITY": "Stato Ultima Attività",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra il comando più recente di riproduzione, pausa o ricerca",
|
||||
"NO_RECENT_COMMANDS": "Nessun comando recente",
|
||||
"LOBBY_HEADER": "LOBBY EPISODIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 In attesa di: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "In attesa dei partecipanti...",
|
||||
"BTN_SKIP_PLAY": "Salta e riproduci comunque",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Annulla la lobby e riproduci comunque",
|
||||
"LOBBY_CONNECT_FIRST": "Connettiti prima a una stanza",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Devi unirti a una stanza tramite un link di invito o crearne una nuova per sincronizzare i video.",
|
||||
"BTN_CREATE_ROOM_ALT": "Crea Nuova Stanza",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crea una nuova stanza casuale ed entra",
|
||||
"LABEL_USERNAME": "Tuo Nome Utente",
|
||||
"LABEL_USERNAME_TOOLTIP": "Il nome utente aiuta gli altri a identificarti.",
|
||||
"PLACEHOLDER_USERNAME": "Koala Anonimo",
|
||||
"LABEL_HIDE_CLUTTER": "Nascondi Schede Inutili",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra le schede senza video e i domini non correlati per mantenere pulito l'elenco",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Auto-Sync Prossimo Episodio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e attende tutti i partecipanti al cambio episodio, poi si avvia in sincro.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Copia Automatica Invito",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente il link di invito negli appunti quando crei una nuova stanza.",
|
||||
"LABEL_NOTIFICATIONS": "Notifiche del Browser",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra le notifiche di sistema quando qualcuno entra/esce o riproduce/mette in pausa.",
|
||||
"LABEL_LANGUAGE": "Lingua Applicazione",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Scegli la lingua preferita per l'estensione",
|
||||
"LABEL_TROUBLESHOOTING": "Risoluzione Problemi",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Strumenti per risolvere problemi di connessione",
|
||||
"BTN_REGEN_ID": "Rigenera ID Peer",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Rigenera il tuo ID interno e riconnettiti",
|
||||
"REGEN_ID_DESC": "Usa questo se riscontri errori di \"Identità Duplicata\".",
|
||||
"REGEN_ID_OTHER_ISSUE": "Altro problema? Apri una Issue su GitHub",
|
||||
"LABEL_CONN_STATUS": "Stato Connessione",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Stato corrente della connessione WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Disconnesso",
|
||||
"BTN_RETRY": "RIPROVA",
|
||||
"BTN_RETRY_TOOLTIP": "Tenta di riconnettersi al server",
|
||||
"BTN_COPY_LOGS": "Copia Log",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copia i log negli appunti per la condivisione",
|
||||
"LABEL_VIDEO_DEBUG": "Info Debug Video",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Dettagli tecnici sull'elemento video attualmente selezionato",
|
||||
"VIDEO_DEBUG_EMPTY": "Nessuna scheda selezionata o video rilevato.",
|
||||
"LABEL_HISTORY": "Cronologia Azioni Completa",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registro cronologico di tutti i comandi di sincronizzazione nella stanza",
|
||||
"HISTORY_EMPTY": "Ancora nessuna attività",
|
||||
"LABEL_LOGS": "Log (Ultimi 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Log di connessione tecnica per il debug",
|
||||
"BTN_CLEAR": "PULISCI",
|
||||
"BTN_CLEAR_TOOLTIP": "Cancella l'output del log",
|
||||
"LABEL_GITHUB": "Repository GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Salta",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Salta il tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Avanti",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Vai al passaggio successivo",
|
||||
"ONBOARDING_1_TITLE": "Benvenuto in KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Guarda i video insieme in perfetta sincronia, non importa dove ti trovi. Facciamo un rapido tour!",
|
||||
"ONBOARDING_2_TITLE": "1. Crea una Stanza",
|
||||
"ONBOARDING_2_TEXT": "Inizia qui. Crea una stanza e condividi il link di invito con i tuoi amici.",
|
||||
"ONBOARDING_3_TITLE": "2. Seleziona Video",
|
||||
"ONBOARDING_3_TEXT": "Naviga qui per selezionare il video che vuoi sincronizzare. Riproduci, metti in pausa e cerca: tutti rimangono sincronizzati.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizza",
|
||||
"ONBOARDING_4_TEXT": "Scegli un nome utente divertente così i tuoi amici sanno chi sei.",
|
||||
"ONBOARDING_5_TITLE": "Tutto pronto!",
|
||||
"ONBOARDING_5_TEXT": "È ora di prendere i popcorn. Buona visione insieme!",
|
||||
"ERR_CONN_TIMEOUT": "Connessione scaduta. Riprova.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato URL del server non valido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento e riprova.",
|
||||
"ERR_NO_PEERS_TIME": "Nessun altro peer con un orario noto. Passa a 'Salto da Me'.",
|
||||
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
|
||||
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
|
||||
"TOAST_INVITE_COPIED": "Link di invito copiato!",
|
||||
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
|
||||
"TOAST_LOBBY_SKIPPED": "Lobby dell'episodio saltata.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Impossibile saltare la lobby.",
|
||||
"TOAST_LOGS_COPIED": "Copiato!",
|
||||
"TOAST_PEER_JOINED": "{name} è entrato nella stanza",
|
||||
"TOAST_PEER_LEFT": "{name} ha lasciato la stanza",
|
||||
"TOAST_PEER_ACTION": "{name} ha {action}",
|
||||
"STATUS_CONNECTED": "Connesso",
|
||||
"STATUS_RECONNECTING": "Riconnessione...",
|
||||
"STATUS_CONNECTING": "Connessione in corso...",
|
||||
"STATUS_FAILED": "Fallito",
|
||||
"STATUS_DISCONNECTED": "Disconnesso",
|
||||
"BTN_STATE_JOINING": "🚀 Entrando...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Riconnessione...",
|
||||
"BTN_STATE_PLAYING": "▶ Riproduzione...",
|
||||
"BTN_STATE_PAUSING": "⏸ In pausa...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione al gruppo ({time})...",
|
||||
"BTN_STATE_SYNCING": "Sincronizzazione...",
|
||||
"BTN_STATE_SYNCED": "✅ Sincronizzato!",
|
||||
"NOTIF_PLAY": "avviato la riproduzione",
|
||||
"NOTIF_PAUSE": "messo in pausa la riproduzione",
|
||||
"NOTIF_SEEK": "cercato nel video",
|
||||
"NOTIF_FORCE_PREPARE": "avviato la sincronizzazione forzata",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizzato tutti",
|
||||
"DEBUG_NO_TAB": "Nessuna scheda di destinazione selezionata.",
|
||||
"DEBUG_COMM_FAIL": "Impossibile comunicare con il video della scheda.",
|
||||
"EMPTY_PEERS_TITLE": "Ancora nessun partecipante",
|
||||
"EMPTY_PEERS_HINT": "Condividi il tuo link di invito per iniziare",
|
||||
"EMPTY_HISTORY_TITLE": "Ancora nessuna attività",
|
||||
"EMPTY_HISTORY_HINT": "Riproduci, metti in pausa o cerca per vedere la cronologia",
|
||||
"EMPTY_LOGS_TITLE": "Nessun log",
|
||||
"EMPTY_LOGS_HINT": "Gli eventi di connessione appariranno qui",
|
||||
"EMPTY_ROOMS_TITLE": "Nessuna stanza attiva",
|
||||
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna per trovare quelle pubbliche",
|
||||
"LABEL_YOU": "Tu",
|
||||
"ONBOARDING_DONE": "Fatto!",
|
||||
"LABEL_LOBBY_PEER_READY": "Pronto",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Caricamento...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Protetto da Password",
|
||||
"LABEL_PEERS_COUNT": "{count} partecipanti",
|
||||
"LABEL_CUSTOM_SERVER": "Server Personalizzato",
|
||||
"BTN_STATE_CREATING": "🚀 Creazione Stanza...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronizzazione Episodio Fallita",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync annullato: {reason}. Potrebbe essere necessaria una sincronizzazione manuale.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Timeout",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (ripristinato)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Tutti gli altri partecipanti hanno lasciato la stanza",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — non tutti i partecipanti hanno caricato l'episodio",
|
||||
"LOBBY_CANCEL_USER": "Annullato dall'utente",
|
||||
"NOTIF_ERROR_TITLE": "Errore KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Offrimi un caffè",
|
||||
"FOOTER_REVIEW": "★ Valuta",
|
||||
"FOOTER_SUPPORT_PROMPT": "Ti piace KoalaSync? Lascia una recensione!",
|
||||
"LABEL_AUDIO_PROCESSING": "Elaborazione audio",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti audio come la compressione alla riproduzione video",
|
||||
"AUDIO_OPEN_SETTINGS": "Apri",
|
||||
"NEW_FEATURE_AUDIO": "Novità: Elaborazione audio — prova il compressore!",
|
||||
"AUDIO_BACK": "← Indietro",
|
||||
"AUDIO_PAGE_TITLE": "Impostazioni audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Elaborazione audio",
|
||||
"AUDIO_COMPRESSOR": "Compressore",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Attivato",
|
||||
"AUDIO_PRESET": "Preimpostazione",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Consigliato",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Gamma dinamica",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Miglioramento vocale",
|
||||
"AUDIO_PRESET_SMOOTH": "Morbido",
|
||||
"AUDIO_PRESET_CUSTOM": "Personalizzato",
|
||||
"AUDIO_PARAM_THRESHOLD": "Soglia",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizzatore",
|
||||
"AUDIO_COMING_SOON": "Prossimamente"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"LANG_CODE": "ja",
|
||||
"HTML_CLASS": "lang-ja",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "ルーム",
|
||||
"TAB_ROOM_TOOLTIP": "ルーム設定と接続",
|
||||
"TAB_SYNC": "同期",
|
||||
"TAB_SYNC_TOOLTIP": "ビデオ同期コントロールとリモートアクション",
|
||||
"TAB_SETTINGS": "設定",
|
||||
"TAB_SETTINGS_TOOLTIP": "拡張機能の設定",
|
||||
"TAB_STATUS": "ステータス",
|
||||
"TAB_STATUS_TOOLTIP": "高度な診断とログ",
|
||||
"BTN_CREATE_ROOM": "+ 新規ルーム作成",
|
||||
"MANUAL_CONNECT_HEADER": "手動接続 / 詳細設定",
|
||||
"LABEL_SERVER": "サーバー",
|
||||
"BTN_SERVER_OFFICIAL": "公式",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "公式の信頼できるサーバーを使用",
|
||||
"BTN_SERVER_CUSTOM": "カスタム",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "独自のセルフホストサーバーに接続",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
|
||||
"LABEL_ROOM_ID": "ルームID",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "同期ルームの固有の識別子",
|
||||
"PLACEHOLDER_ROOM_ID": "ルームIDを入力",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "参加したいルームの固有のID",
|
||||
"LABEL_PASSWORD": "パスワード(任意)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "ルームへのアクセスを制限するための任意のパスワード",
|
||||
"PLACEHOLDER_PASSWORD": "ルームのパスワード(任意)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "ルームのパスワード(ない場合は空欄)",
|
||||
"BTN_JOIN_ROOM": "ルームに参加 / 作成",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "ルームに接続",
|
||||
"LABEL_PUBLIC_ROOMS": "公開ルーム",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "このサーバー上の公開ルーム의リスト",
|
||||
"BTN_REFRESH": "更新",
|
||||
"BTN_REFRESH_TOOLTIP": "公開ルームのリストを更新",
|
||||
"PUBLIC_ROOMS_REFRESHING": "更新中...",
|
||||
"BTN_REFRESH_COOLDOWN": "{seconds}秒待機",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "ルームリスト更新のクールダウン中。{seconds}秒後に再試行してください。",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "公開ルームを更新中。次の更新まであと{seconds}秒。",
|
||||
"LABEL_ACTIVE_ROOM": "アクティブなルーム",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "現在接続しているルーム",
|
||||
"ACTIVE_ROOM_NONE": "なし",
|
||||
"ACTIVE_SERVER_OFFICIAL": "公式サーバー",
|
||||
"LABEL_INVITE_LINK": "招待リンク",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "友達とこのリンクを共有して参加してもらいましょう",
|
||||
"LABEL_PEERS_IN_ROOM": "ルーム内のメンバー",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "現在このルームに接続している他のユーザー",
|
||||
"NO_PEERS_CONNECTED": "接続しているメンバーはいません",
|
||||
"BTN_LEAVE_ROOM": "ルームを退室",
|
||||
"LABEL_SELECT_VIDEO": "ビデオを選択",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "同期するビデオが含まれるブラウザのタブを選択してください",
|
||||
"OPTION_SELECT_TAB": "-- タブを選択してください --",
|
||||
"LABEL_REMOTE_CONTROL": "リモートコントロール",
|
||||
"BTN_COPY_INVITE": "📋 招待リンク",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "招待リンクをコピー",
|
||||
"BTN_PLAY": "▶ 再生",
|
||||
"BTN_PLAY_TOOLTIP": "全員に再生コマンドを送信",
|
||||
"BTN_PAUSE": "⏸ 一時停止",
|
||||
"BTN_PAUSE_TOOLTIP": "全員に一時停止コマンドを送信",
|
||||
"BTN_SYNC": "⚡ 同期",
|
||||
"BTN_SYNC_TOOLTIP": "すべてのユーザーを強制的に同期",
|
||||
"OPTION_JUMP_TO_OTHERS": "他の人に合わせる",
|
||||
"OPTION_JUMP_TO_ME": "自分に合わせる",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "同期対象を選択",
|
||||
"LABEL_LAST_ACTIVITY": "最新のアクティビティ状況",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "最新の再生、一時停止、またはシークコマンドを表示します",
|
||||
"NO_RECENT_COMMANDS": "最近のコマンドはありません",
|
||||
"LOBBY_HEADER": "エピソードロビー",
|
||||
"LOBBY_WAITING_FOR": "🎬 待機中: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "メンバーを待機中...",
|
||||
"BTN_SKIP_PLAY": "スキップして再生",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "ロビーをキャンセルして再生します",
|
||||
"LOBBY_CONNECT_FIRST": "最初にルームに接続してください",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "ビデオを同期するには、招待リンクからルームに参加するか、新規に作成する必要があります。",
|
||||
"BTN_CREATE_ROOM_ALT": "新規ルーム作成",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "ランダムな新規ルームを作成して参加",
|
||||
"LABEL_USERNAME": "ユーザー名",
|
||||
"LABEL_USERNAME_TOOLTIP": "ユーザー名は他のメンバーがあなたを識別するのに役立ちます。",
|
||||
"PLACEHOLDER_USERNAME": "匿名コアラ",
|
||||
"LABEL_HIDE_CLUTTER": "不要なタブを非表示",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "リストをすっきりさせるため、ビデオのないタブや無関係なドメインをフィルタリングします",
|
||||
"LABEL_AUTO_SYNC_NEXT": "次のエピソードを自動同期",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "エピソード変更時に自動的に一時停止して全員を待ち、準備ができたら同時に再生を開始します。",
|
||||
"LABEL_AUTO_COPY_INVITE": "招待リンク自動コピー",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "新規ルーム作成時に招待リンクをクリップボードに自動コピーします。",
|
||||
"LABEL_NOTIFICATIONS": "ブラウザ通知",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "誰かが参加/退室したとき、または再生/一時停止したときにシステムの通知を表示します。",
|
||||
"LABEL_LANGUAGE": "アプリの言語",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "拡張機能の優先言語を選択してください",
|
||||
"LABEL_TROUBLESHOOTING": "トラブルシューティング",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "接続の問題を修正するためのツール",
|
||||
"BTN_REGEN_ID": "ピアIDの再生成",
|
||||
"BTN_REGEN_ID_TOOLTIP": "内部IDを再生成して再接続します",
|
||||
"REGEN_ID_DESC": "「Duplicate Identity」(ID重複)エラーが表示される場合に使用します。",
|
||||
"REGEN_ID_OTHER_ISSUE": "他の問題?GitHub Issueを開く",
|
||||
"LABEL_CONN_STATUS": "接続状態",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "現在のWebSocket接続状態",
|
||||
"CONN_STATUS_DISCONNECTED": "切断されました",
|
||||
"BTN_RETRY": "再試行",
|
||||
"BTN_RETRY_TOOLTIP": "サーバーへの再接続を試行",
|
||||
"BTN_COPY_LOGS": "ログをコピー",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "共有用にログをクリップボードにコピー",
|
||||
"LABEL_VIDEO_DEBUG": "ビデオデバッグ情報",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "現在選択されているビデオ要素に関する技術的詳細",
|
||||
"VIDEO_DEBUG_EMPTY": "タブが選択されていないか、ビデオが検出されませんでした。",
|
||||
"LABEL_HISTORY": "全アクション履歴",
|
||||
"LABEL_HISTORY_TOOLTIP": "ルーム内のすべての同期コマンドの時系列ログ",
|
||||
"HISTORY_EMPTY": "アクティビティはまだありません",
|
||||
"LABEL_LOGS": "ログ(直近50件)",
|
||||
"LABEL_LOGS_TOOLTIP": "デバッグ用の技術接続ログ",
|
||||
"BTN_CLEAR": "消去",
|
||||
"BTN_CLEAR_TOOLTIP": "ログ出力をクリア",
|
||||
"LABEL_GITHUB": "GitHubリポジトリ",
|
||||
"BTN_ONBOARDING_SKIP": "スキップ",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "チュートリアルをスキップ",
|
||||
"BTN_ONBOARDING_NEXT": "次へ",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "次のステップに進む",
|
||||
"ONBOARDING_1_TITLE": "KoalaSyncへようこそ!",
|
||||
"ONBOARDING_1_TEXT": "どこにいても、完璧に同期してビデオを一緒に楽しめます。簡単なツアーを始めましょう!",
|
||||
"ONBOARDING_2_TITLE": "1. ルームの作成",
|
||||
"ONBOARDING_2_TEXT": "ここからスタート。ルームを作成し、招待リンクを友達に共有します。",
|
||||
"ONBOARDING_3_TITLE": "2. ビデオの選択",
|
||||
"ONBOARDING_3_TEXT": "ここで同期したいビデオを選択します。再生、一時停止、シーク操作は全員に同期されます。",
|
||||
"ONBOARDING_4_TITLE": "3. カスタマイズ",
|
||||
"ONBOARDING_4_TEXT": "友達にわかりやすいように、楽しいユーザー名を設定しましょう。",
|
||||
"ONBOARDING_5_TITLE": "準備が整いました!",
|
||||
"ONBOARDING_5_TEXT": "ポップコーンを用意しましょう。一緒に鑑賞を楽しんでください!",
|
||||
"ERR_CONN_TIMEOUT": "接続がタイムアウトしました。再試行してください。",
|
||||
"ERR_INVALID_SERVER_URL": "サーバーURLの形式が無効です。",
|
||||
"ERR_IDENTITY_NOT_LOADED": "IDがまだロードされていません。しばらく待ってから再試行してください。",
|
||||
"ERR_NO_PEERS_TIME": "既知の時間を持つ他のメンバーがいません。「自分に合わせる」に切り替えてください。",
|
||||
"ERR_NO_VIDEO_TAB": "ビデオタブに接続できませんでした。",
|
||||
"ERR_SELECT_VIDEO": "最初にビデオを選択してください!",
|
||||
"TOAST_INVITE_COPIED": "招待リンクをコピーしました!",
|
||||
"TOAST_COPY_FAILED": "クリップボードへのコピーに失敗しました",
|
||||
"TOAST_LOBBY_SKIPPED": "エピソードロビーをスキップしました。",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "ロビーのスキップに失敗しました。",
|
||||
"TOAST_LOGS_COPIED": "コピーしました!",
|
||||
"TOAST_PEER_JOINED": "{name}が参加しました",
|
||||
"TOAST_PEER_LEFT": "{name}が退室しました",
|
||||
"TOAST_PEER_ACTION": "{name}が{action}",
|
||||
"STATUS_CONNECTED": "接続完了",
|
||||
"STATUS_RECONNECTING": "再接続中...",
|
||||
"STATUS_CONNECTING": "接続中...",
|
||||
"STATUS_FAILED": "失敗",
|
||||
"STATUS_DISCONNECTED": "切断されました",
|
||||
"BTN_STATE_JOINING": "🚀 参加中...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 再接続中...",
|
||||
"BTN_STATE_PLAYING": "▶ 再生中...",
|
||||
"BTN_STATE_PAUSING": "⏸ 一時停止中...",
|
||||
"BTN_STATE_SYNCING_GROUP": "グループに同期中 ({time})...",
|
||||
"BTN_STATE_SYNCING": "同期中...",
|
||||
"BTN_STATE_SYNCED": "✅ 同期完了!",
|
||||
"NOTIF_PLAY": "再生を開始しました",
|
||||
"NOTIF_PAUSE": "再生を一時停止しました",
|
||||
"NOTIF_SEEK": "動画をシークしました",
|
||||
"NOTIF_FORCE_PREPARE": "強制同期を開始しました",
|
||||
"NOTIF_FORCE_EXECUTE": "全員を同期しました",
|
||||
"DEBUG_NO_TAB": "対象のタブが選択されていません。",
|
||||
"DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。",
|
||||
"EMPTY_PEERS_TITLE": "メンバーはまだいません",
|
||||
"EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう",
|
||||
"EMPTY_HISTORY_TITLE": "アクティビティはまだありません",
|
||||
"EMPTY_HISTORY_HINT": "履歴を表示するには再生、一時停止、またはシークを行います",
|
||||
"EMPTY_LOGS_TITLE": "ログはありません",
|
||||
"EMPTY_LOGS_HINT": "接続イベントがここに表示されます",
|
||||
"EMPTY_ROOMS_TITLE": "アクティブなルームはありません",
|
||||
"EMPTY_ROOMS_HINT": "ルームを作成するか、更新して公開ルームを探します",
|
||||
"LABEL_YOU": "あなた",
|
||||
"ONBOARDING_DONE": "完了!",
|
||||
"LABEL_LOBBY_PEER_READY": "準備完了",
|
||||
"LABEL_LOBBY_PEER_LOADING": "読み込み中...",
|
||||
"LABEL_PASSWORD_PROTECTED": "パスワード保護",
|
||||
"LABEL_PEERS_COUNT": "{count}人",
|
||||
"LABEL_CUSTOM_SERVER": "カスタムサーバー",
|
||||
"BTN_STATE_CREATING": "🚀 ルーム作成中...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — エピソード同期に失敗しました",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "自動同期がキャンセルされました: {reason}。手動で同期する必要がある場合があります。",
|
||||
"LOBBY_CANCEL_TIMEOUT": "タイムアウト",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "タイムアウト(回復済)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "他のすべてのメンバーが退室しました",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "タイムアウト — 一部のメンバーがエピソードを読み込めませんでした",
|
||||
"LOBBY_CANCEL_USER": "ユーザーによってキャンセルされました",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSyncエラー",
|
||||
"FOOTER_SUPPORT": "☕ コーヒーをおごってね",
|
||||
"FOOTER_REVIEW": "★ 評価する",
|
||||
"FOOTER_SUPPORT_PROMPT": "KoalaSyncはいかが?レビューを書いてください!",
|
||||
"LABEL_AUDIO_PROCESSING": "オーディオ処理",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "動画再生に圧縮などのオーディオエフェクトを適用します",
|
||||
"AUDIO_OPEN_SETTINGS": "開く",
|
||||
"NEW_FEATURE_AUDIO": "新機能: オーディオ処理 — コンプレッサーを試してみよう!",
|
||||
"AUDIO_BACK": "← 戻る",
|
||||
"AUDIO_PAGE_TITLE": "オーディオ設定",
|
||||
"AUDIO_MASTER_TOGGLE": "オーディオ処理",
|
||||
"AUDIO_COMPRESSOR": "コンプレッサー",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "有効",
|
||||
"AUDIO_PRESET": "プリセット",
|
||||
"AUDIO_PRESET_RECOMMENDED": "推奨",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "ダイナミックレンジ",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "ボーカル強調",
|
||||
"AUDIO_PRESET_SMOOTH": "スムース",
|
||||
"AUDIO_PRESET_CUSTOM": "カスタム",
|
||||
"AUDIO_PARAM_THRESHOLD": "スレッショルド",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "イコライザー",
|
||||
"AUDIO_COMING_SOON": "近日公開"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"LANG_CODE": "ko",
|
||||
"HTML_CLASS": "lang-ko",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "방",
|
||||
"TAB_ROOM_TOOLTIP": "방 설정 및 연결",
|
||||
"TAB_SYNC": "동기화",
|
||||
"TAB_SYNC_TOOLTIP": "비디오 동기화 제어 및 원격 작업",
|
||||
"TAB_SETTINGS": "설정",
|
||||
"TAB_SETTINGS_TOOLTIP": "확장 프로그램 기본 설정",
|
||||
"TAB_STATUS": "상태",
|
||||
"TAB_STATUS_TOOLTIP": "고급 진단 및 로그",
|
||||
"BTN_CREATE_ROOM": "+ 새 방 만들기",
|
||||
"MANUAL_CONNECT_HEADER": "수동 연결 / 고급",
|
||||
"LABEL_SERVER": "서버",
|
||||
"BTN_SERVER_OFFICIAL": "공식",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "공식적이고 신뢰할 수 있는 서버 사용",
|
||||
"BTN_SERVER_CUSTOM": "사용자 정의",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "자체 호스팅 서버에 연결",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
|
||||
"LABEL_ROOM_ID": "방 ID",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "동기화 방의 고유 식별자",
|
||||
"PLACEHOLDER_ROOM_ID": "방 ID 입력",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "참여하려는 방의 고유 ID",
|
||||
"LABEL_PASSWORD": "비밀번호 (선택사항)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "방 액세스를 제한하기 위한 선택적 비밀번호",
|
||||
"PLACEHOLDER_PASSWORD": "방 비밀번호 (선택사항)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "방 비밀번호 (없으면 비워둠)",
|
||||
"BTN_JOIN_ROOM": "방 참여 / 만들기",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "방에 연결",
|
||||
"LABEL_PUBLIC_ROOMS": "공개 방",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "이 서버의 공개 방 목록",
|
||||
"BTN_REFRESH": "Refreschi",
|
||||
"BTN_REFRESH_TOOLTIP": "공개 방 목록 새로고침",
|
||||
"PUBLIC_ROOMS_REFRESHING": "새로고침 중...",
|
||||
"BTN_REFRESH_COOLDOWN": "{seconds}초 대기",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "방 목록 새로고침 대기 중입니다. {seconds}초 후에 다시 시도하세요.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "공개 방 새로고침 중. {seconds}초 후 새로고침 가능.",
|
||||
"LABEL_ACTIVE_ROOM": "활성 방",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "현재 연결된 방",
|
||||
"ACTIVE_ROOM_NONE": "없음",
|
||||
"ACTIVE_SERVER_OFFICIAL": "공식 서버",
|
||||
"LABEL_INVITE_LINK": "초대 링크",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "친구들이 참여할 수 있도록 이 링크를 공유하세요",
|
||||
"LABEL_PEERS_IN_ROOM": "방의 참여자",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "현재 이 방에 연결되어 있는 다른 사용자들",
|
||||
"NO_PEERS_CONNECTED": "연결된 참여자가 없습니다",
|
||||
"BTN_LEAVE_ROOM": "방 나가기",
|
||||
"LABEL_SELECT_VIDEO": "비디오 선택",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "동기화할 비디오가 포함된 브라우저 탭을 선택하세요",
|
||||
"OPTION_SELECT_TAB": "-- 탭 선택 --",
|
||||
"LABEL_REMOTE_CONTROL": "원격 제어",
|
||||
"BTN_COPY_INVITE": "📋 초대 링크",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "초대 링크 복사",
|
||||
"BTN_PLAY": "▶ 재생",
|
||||
"BTN_PLAY_TOOLTIP": "모든 사람에게 재생 명령 전송",
|
||||
"BTN_PAUSE": "⏸ 일시정지",
|
||||
"BTN_PAUSE_TOOLTIP": "모든 사람에게 일시정지 명령 전송",
|
||||
"BTN_SYNC": "⚡ 동기화",
|
||||
"BTN_SYNC_TOOLTIP": "모든 사용자의 강제 동기화",
|
||||
"OPTION_JUMP_TO_OTHERS": "다른 사람에게 이동",
|
||||
"OPTION_JUMP_TO_ME": "나에게 이동",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "동기화 대상 선택",
|
||||
"LABEL_LAST_ACTIVITY": "최근 활동 상태",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "가장 최근의 재생, 일시정지, 탐색 명령을 표시합니다",
|
||||
"NO_RECENT_COMMANDS": "최근 명령 없음",
|
||||
"LOBBY_HEADER": "에피소드 로비",
|
||||
"LOBBY_WAITING_FOR": "🎬 대기 중: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "참여자 대기 중...",
|
||||
"BTN_SKIP_PLAY": "건너뛰고 재생",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "로비를 취소하고 재생합니다",
|
||||
"LOBBY_CONNECT_FIRST": "먼저 방에 연결하세요",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "비디오를 동기화하려면 초대 링크를 통해 방에 참여하거나 새 방을 만들어야 합니다.",
|
||||
"BTN_CREATE_ROOM_ALT": "새 방 만들기",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "새로운 무작위 방을 만들고 참여",
|
||||
"LABEL_USERNAME": "사용자 이름",
|
||||
"LABEL_USERNAME_TOOLTIP": "사용자 이름은 다른 사람들이 귀하를 식별하는 데 도움이 됩니다.",
|
||||
"PLACEHOLDER_USERNAME": "익명의 코알라",
|
||||
"LABEL_HIDE_CLUTTER": "복잡한 탭 숨기기",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "목록을 깔끔하게 유지하기 위해 비디오가 없는 탭과 관련 없는 도메인을 필터링합니다",
|
||||
"LABEL_AUTO_SYNC_NEXT": "다음 에피소드 자동 동기화",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "에피소드가 변경되면 자동으로 일시정지하고 모든 참여자를 기다린 후 함께 동기화하여 시작합니다.",
|
||||
"LABEL_AUTO_COPY_INVITE": "초대 링크 자동 복사",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "새 방을 만들 때 초대 링크를 클립보드에 자동으로 복사합니다.",
|
||||
"LABEL_NOTIFICATIONS": "브라우저 알림",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "누군가 참여/퇴장하거나 재생/일시정지할 때 시스템 알림을 표시합니다.",
|
||||
"LABEL_LANGUAGE": "앱 언어",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "선호하는 확장 프로그램 언어를 선택하세요",
|
||||
"LABEL_TROUBLESHOOTING": "문제 해결",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "연결 문제 해결을 위한 도구",
|
||||
"BTN_REGEN_ID": "피어 ID 재생성",
|
||||
"BTN_REGEN_ID_TOOLTIP": "내부 ID를 재생성하고 다시 연결합니다",
|
||||
"REGEN_ID_DESC": "\"중복된 ID\" 오류가 발생할 경우에 사용하세요.",
|
||||
"REGEN_ID_OTHER_ISSUE": "다른 문제? GitHub Issue 열기",
|
||||
"LABEL_CONN_STATUS": "연결 상태",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "현재 WebSocket 연결 상태",
|
||||
"CONN_STATUS_DISCONNECTED": "연결 끊김",
|
||||
"BTN_RETRY": "재시도",
|
||||
"BTN_RETRY_TOOLTIP": "서버에 재연결 시도",
|
||||
"BTN_COPY_LOGS": "로그 복사",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "공유를 위해 로그를 클립보드에 복사",
|
||||
"LABEL_VIDEO_DEBUG": "비디오 디버그 정보",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "현재 선택된 비디오 요소에 대한 기술적 세부 정보",
|
||||
"VIDEO_DEBUG_EMPTY": "선택된 탭이 없거나 비디오가 감지되지 않았습니다.",
|
||||
"LABEL_HISTORY": "전체 작업 내역",
|
||||
"LABEL_HISTORY_TOOLTIP": "방 내 모든 동기화 명령의 시간순 로그",
|
||||
"HISTORY_EMPTY": "활동 없음",
|
||||
"LABEL_LOGS": "로그 (최근 50개)",
|
||||
"LABEL_LOGS_TOOLTIP": "디버깅용 기술 연결 로그",
|
||||
"BTN_CLEAR": "지우기",
|
||||
"BTN_CLEAR_TOOLTIP": "로그 출력 지우기",
|
||||
"LABEL_GITHUB": "GitHub 저장소",
|
||||
"BTN_ONBOARDING_SKIP": "건너뛰기",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "튜토리얼 건너뛰기",
|
||||
"BTN_ONBOARDING_NEXT": "다음",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "다음 단계로 이동",
|
||||
"ONBOARDING_1_TITLE": "KoalaSync에 오신 것을 환영합니다!",
|
||||
"ONBOARDING_1_TEXT": "어디에 있든 완벽하게 동기화된 비디오를 함께 시청하세요. 간단히 둘러볼까요!",
|
||||
"ONBOARDING_2_TITLE": "1. 방 만들기",
|
||||
"ONBOARDING_2_TEXT": "여기서 시작하세요. 방을 만들고 친구들에게 초대 링크를 공유하세요.",
|
||||
"ONBOARDING_3_TITLE": "2. 비디오 선택",
|
||||
"ONBOARDING_3_TEXT": "동기화할 비디오를 선택하려면 여기로 이동하세요. 재생, 일시정지, 탐색이 모든 참여자에게 동기화됩니다.",
|
||||
"ONBOARDING_4_TITLE": "3. 개인화",
|
||||
"ONBOARDING_4_TEXT": "친구들이 나를 알아볼 수 있도록 재미있는 사용자 이름을 골라보세요.",
|
||||
"ONBOARDING_5_TITLE": "모든 준비가 끝났습니다!",
|
||||
"ONBOARDING_5_TEXT": "팝콘을 준비할 시간입니다. 함께 즐겁게 시청하세요!",
|
||||
"ERR_CONN_TIMEOUT": "연결 시간이 초과되었습니다. 다시 시도해 주세요.",
|
||||
"ERR_INVALID_SERVER_URL": "올바르지 않은 서버 URL 형식입니다.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"ERR_NO_PEERS_TIME": "시간을 알고 있는 다른 피어가 없습니다. '나에게 이동'으로 전환하세요.",
|
||||
"ERR_NO_VIDEO_TAB": "비디오 탭에 연결할 수 없습니다.",
|
||||
"ERR_SELECT_VIDEO": "먼저 비디오를 선택하세요!",
|
||||
"TOAST_INVITE_COPIED": "초대 링크가 복사되었습니다!",
|
||||
"TOAST_COPY_FAILED": "클립보드 복사에 실패했습니다",
|
||||
"TOAST_LOBBY_SKIPPED": "에피소드 로비를 건너뛰었습니다.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "로비 건너뛰기에 실패했습니다.",
|
||||
"TOAST_LOGS_COPIED": "복사됨!",
|
||||
"TOAST_PEER_JOINED": "{name} 님이 방에 참여했습니다",
|
||||
"TOAST_PEER_LEFT": "{name} 님이 방에서 나갔습니다",
|
||||
"TOAST_PEER_ACTION": "{name} 님이 {action}",
|
||||
"STATUS_CONNECTED": "연결됨",
|
||||
"STATUS_RECONNECTING": "재연결 중...",
|
||||
"STATUS_CONNECTING": "연kel 중...",
|
||||
"STATUS_FAILED": "실패",
|
||||
"STATUS_DISCONNECTED": "연결 끊김",
|
||||
"BTN_STATE_JOINING": "🚀 참여 중...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 재연결 중...",
|
||||
"BTN_STATE_PLAYING": "▶ 재생 중...",
|
||||
"BTN_STATE_PAUSING": "⏸ 일시정지 중...",
|
||||
"BTN_STATE_SYNCING_GROUP": "그룹에 동기화 중 ({time})...",
|
||||
"BTN_STATE_SYNCING": "동기화 중...",
|
||||
"BTN_STATE_SYNCED": "✅ 동기화됨!",
|
||||
"NOTIF_PLAY": "재생을 시작했습니다",
|
||||
"NOTIF_PAUSE": "재생을 일시정지했습니다",
|
||||
"NOTIF_SEEK": "비디오를 탐색했습니다",
|
||||
"NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다",
|
||||
"NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다",
|
||||
"DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.",
|
||||
"DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.",
|
||||
"EMPTY_PEERS_TITLE": "참여자 없음",
|
||||
"EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요",
|
||||
"EMPTY_HISTORY_TITLE": "활동 없음",
|
||||
"EMPTY_HISTORY_HINT": "기록을 보려면 재생, 일시정지 또는 탐색을 해보세요",
|
||||
"EMPTY_LOGS_TITLE": "로그 없음",
|
||||
"EMPTY_LOGS_HINT": "연결 이벤트가 여기에 표시됩니다",
|
||||
"EMPTY_ROOMS_TITLE": "활성 방 없음",
|
||||
"EMPTY_ROOMS_HINT": "방을 만들거나 새로고침하여 공개 방을 찾으세요",
|
||||
"LABEL_YOU": "나",
|
||||
"ONBOARDING_DONE": "완료!",
|
||||
"LABEL_LOBBY_PEER_READY": "준비",
|
||||
"LABEL_LOBBY_PEER_LOADING": "로딩 중...",
|
||||
"LABEL_PASSWORD_PROTECTED": "비밀번호 보호됨",
|
||||
"LABEL_PEERS_COUNT": "{count}명",
|
||||
"LABEL_CUSTOM_SERVER": "사용자 정의 서버",
|
||||
"BTN_STATE_CREATING": "🚀 방 만드는 중...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — 에피소드 동기화 실패",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "자동 동기화 취소됨: {reason}. 수동 동기화가 필요할 수 있습니다.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "시간 초과",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "시간 초과 (복구됨)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "다른 모든 피어가 나갔습니다",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "시간 초과 — 모든 피어가 에피소드를 로드하지 못했습니다",
|
||||
"LOBBY_CANCEL_USER": "사용자에 의해 취소됨",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync 오류",
|
||||
"FOOTER_SUPPORT": "☕ 커피 한 잔 사주세요",
|
||||
"FOOTER_REVIEW": "★ 평가하기",
|
||||
"FOOTER_SUPPORT_PROMPT": "KoalaSync가 마음에 드세요? 리뷰를 남겨주세요!",
|
||||
"LABEL_AUDIO_PROCESSING": "오디오 처리",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "비디오 재생에 압축과 같은 오디오 효과를 적용합니다",
|
||||
"AUDIO_OPEN_SETTINGS": "열기",
|
||||
"NEW_FEATURE_AUDIO": "신규: 오디오 처리 — 컴프레서를 사용해보세요!",
|
||||
"AUDIO_BACK": "← 뒤로",
|
||||
"AUDIO_PAGE_TITLE": "오디오 설정",
|
||||
"AUDIO_MASTER_TOGGLE": "오디오 처리",
|
||||
"AUDIO_COMPRESSOR": "컴프레서",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "활성화",
|
||||
"AUDIO_PRESET": "프리셋",
|
||||
"AUDIO_PRESET_RECOMMENDED": "추천",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "다이내믹 레인지",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "음성 강화",
|
||||
"AUDIO_PRESET_SMOOTH": "부드럽게",
|
||||
"AUDIO_PRESET_CUSTOM": "사용자 정의",
|
||||
"AUDIO_PARAM_THRESHOLD": "임계값",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "이퀄라이저",
|
||||
"AUDIO_COMING_SOON": "출시 예정"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"LANG_CODE": "nl",
|
||||
"HTML_CLASS": "lang-nl",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Kamer",
|
||||
"TAB_ROOM_TOOLTIP": "Kamerinstellingen en verbinding",
|
||||
"TAB_SYNC": "Sync",
|
||||
"TAB_SYNC_TOOLTIP": "Videobeheer en externe acties",
|
||||
"TAB_SETTINGS": "Instellingen",
|
||||
"TAB_SETTINGS_TOOLTIP": "Extensievoorkeuren",
|
||||
"TAB_STATUS": "Status",
|
||||
"TAB_STATUS_TOOLTIP": "Geavanceerde diagnostische gegevens & logs",
|
||||
"BTN_CREATE_ROOM": "+ Nieuwe kamer maken",
|
||||
"MANUAL_CONNECT_HEADER": "Handmatig verbinden / Geavanceerd",
|
||||
"LABEL_SERVER": "Server",
|
||||
"BTN_SERVER_OFFICIAL": "Officieel",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Gebruik de officiële betrouwbare server",
|
||||
"BTN_SERVER_CUSTOM": "Aangepast",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Verbind met uw eigen gehoste server",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://uw-server:3000",
|
||||
"LABEL_ROOM_ID": "Kamer-ID",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "De unieke identificatie voor uw synchronisatiekamer",
|
||||
"PLACEHOLDER_ROOM_ID": "Voer kamer-ID in",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Het unieke ID van de kamer waar u aan wilt deelnemen",
|
||||
"LABEL_PASSWORD": "Wachtwoord (optioneel)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Optioneel wachtwoord om toegang tot de kamer te beperken",
|
||||
"PLACEHOLDER_PASSWORD": "Kamerwachtwoord (optioneel)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Wachtwoord voor de kamer (leeg laten indien geen)",
|
||||
"BTN_JOIN_ROOM": "Deelnemen / Kamer maken",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Verbinden met de kamer",
|
||||
"LABEL_PUBLIC_ROOMS": "Openbare kamers",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lijst met openbaar beschikbare kamers op deze server",
|
||||
"BTN_REFRESH": "VERNIEUWEN",
|
||||
"BTN_REFRESH_TOOLTIP": "Vernieuw de lijst met openbare kamers",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Vernieuwen...",
|
||||
"BTN_REFRESH_COOLDOWN": "WACHT {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Vernieuwen van kamerlijst is aan het afkoelen. Probeer opnieuw in {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Openbare kamers vernieuwen. Volgende verversing beschikbaar in {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Actieve kamer",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "De kamer waar u momenteel mee bent verbonden",
|
||||
"ACTIVE_ROOM_NONE": "GEEN",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Officiële server",
|
||||
"LABEL_INVITE_LINK": "Uitnodigingslink",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Deel deze link met vrienden zodat ze kunnen deelnemen",
|
||||
"LABEL_PEERS_IN_ROOM": "Deelnemers in kamer",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere gebruikers die momenteel zijn verbonden met deze kamer",
|
||||
"NO_PEERS_CONNECTED": "Geen deelnemers verbonden",
|
||||
"BTN_LEAVE_ROOM": "Kamer verlaten",
|
||||
"LABEL_SELECT_VIDEO": "Selecteer video",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Kies het browsertabblad met de video die u wilt synchroniseren",
|
||||
"OPTION_SELECT_TAB": "-- Selecteer een tabblad --",
|
||||
"LABEL_REMOTE_CONTROL": "Afstandsbediening",
|
||||
"BTN_COPY_INVITE": "📋 Uitnodigingslink",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Uitnodigingslink kopiëren",
|
||||
"BTN_PLAY": "▶ Afspelen",
|
||||
"BTN_PLAY_TOOLTIP": "Stuur een afspeelopdracht naar iedereen",
|
||||
"BTN_PAUSE": "⏸ Pauzeren",
|
||||
"BTN_PAUSE_TOOLTIP": "Stuur een pauzeeropdracht naar iedereen",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Dwing alle gebruikers om te synchroniseren",
|
||||
"OPTION_JUMP_TO_OTHERS": "Spring naar anderen",
|
||||
"OPTION_JUMP_TO_ME": "Spring naar mij",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Kies synchronisatiedoel",
|
||||
"LABEL_LAST_ACTIVITY": "Laatste activiteitsstatus",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Toont de meest recente afspeel-, pauzeer- of zoekopdracht",
|
||||
"NO_RECENT_COMMANDS": "Geen recente commando's",
|
||||
"LOBBY_HEADER": "EPISODE LOBBY",
|
||||
"LOBBY_WAITING_FOR": "🎬 Wachten op: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Wachten op deelnemers...",
|
||||
"BTN_SKIP_PLAY": "Overslaan & toch afspelen",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Lobby annuleren en toch afspelen",
|
||||
"LOBBY_CONNECT_FIRST": "Verbind eerst met een kamer",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "U moet deelnemen aan een kamer via een uitnodigingslink of een nieuwe maken om video's te synchroniseren.",
|
||||
"BTN_CREATE_ROOM_ALT": "Nieuwe kamer maken",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Maak een nieuwe willekeurige kamer en neem deel",
|
||||
"LABEL_USERNAME": "Uw gebruikersnaam",
|
||||
"LABEL_USERNAME_TOOLTIP": "Gebruikersnaam helpt anderen u te identificeren.",
|
||||
"PLACEHOLDER_USERNAME": "Anonieme Koala",
|
||||
"LABEL_HIDE_CLUTTER": "Overbodige tabbladen verbergen",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtert tabbladen zonder video's en niet-gerelateerde domeinen om de lijst schoon te houden",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Volgende aflevering automatisch syncen",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pauzeert automatisch en wacht op alle deelnemers wanneer een aflevering verandert, en start dan gezamenlijk gesynchroniseerd.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Uitnodigingslink automatisch kopiëren",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Kopieert de uitnodigingslink automatisch naar uw klembord bij het maken van een nieuwe kamer.",
|
||||
"LABEL_NOTIFICATIONS": "Browsernotificaties",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Toont systeemnotificaties wanneer iemand deelneemt/verlaat of afspeelt/pauzeert.",
|
||||
"LABEL_LANGUAGE": "Taal app",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Kies uw favoriete taal voor de extensie",
|
||||
"LABEL_TROUBLESHOOTING": "Problemen oplossen",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Hulpmiddelen voor het oplossen van verbindingsproblemen",
|
||||
"BTN_REGEN_ID": "Peer-ID opnieuw genereren",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Genereer uw interne ID opnieuw en maak opnieuw verbinding",
|
||||
"REGEN_ID_DESC": "Gebruik dit als u fouten ziet over 'Duplicate Identity'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Ander probleem? Open een GitHub Issue",
|
||||
"LABEL_CONN_STATUS": "Verbindingsstatus",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Huidige WebSocket-verbindingsstatus",
|
||||
"CONN_STATUS_DISCONNECTED": "Verbinding verbroken",
|
||||
"BTN_RETRY": "OPNIEUW PROBEREN",
|
||||
"BTN_RETRY_TOOLTIP": "Proberen opnieuw verbinding te maken met de server",
|
||||
"BTN_COPY_LOGS": "Logs kopiëren",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Kopieer logboeken naar het klembord om te delen",
|
||||
"LABEL_VIDEO_DEBUG": "Video-debuginfo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Technische details over het momenteel geselecteerde video-element",
|
||||
"VIDEO_DEBUG_EMPTY": "Geen tabblad geselecteerd of video gedetecteerd.",
|
||||
"LABEL_HISTORY": "Volledige actiegeschiedenis",
|
||||
"LABEL_HISTORY_TOOLTIP": "Chronologisch logboek van alle synchronisatieopdrachten in de kamer",
|
||||
"HISTORY_EMPTY": "Nog geen activiteit",
|
||||
"LABEL_LOGS": "Logs (Laatste 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Technische verbindingslogboeken voor debuggen",
|
||||
"BTN_CLEAR": "WISSEN",
|
||||
"BTN_CLEAR_TOOLTIP": "Logboekuitvoer wissen",
|
||||
"LABEL_GITHUB": "GitHub-repository",
|
||||
"BTN_ONBOARDING_SKIP": "Overslaan",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Sla de tutorial over",
|
||||
"BTN_ONBOARDING_NEXT": "Volgende",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ga naar de volgende stap",
|
||||
"ONBOARDING_1_TITLE": "Welkom bij KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Bekijk video's samen in perfecte synchronisatie — waar u ook bent. Laten we een snelle rondleiding nemen!",
|
||||
"ONBOARDING_2_TITLE": "1. Kamer maken",
|
||||
"ONBOARDING_2_TEXT": "Begin hier. Maak een kamer en deel de uitnodigingslink met uw vrienden.",
|
||||
"ONBOARDING_3_TITLE": "2. Selecteer video",
|
||||
"ONBOARDING_3_TEXT": "Navigeer hiernaartoe om de video te selecteren die u wilt synchroniseren. Spelen, pauzeren en zoeken — iedereen blijft synchroon.",
|
||||
"ONBOARDING_4_TITLE": "3. Personaliseren",
|
||||
"ONBOARDING_4_TEXT": "Kies een leuke gebruikersnaam zodat uw vrienden weten wie u bent.",
|
||||
"ONBOARDING_5_TITLE": "U bent er helemaal klaar voor!",
|
||||
"ONBOARDING_5_TEXT": "Tijd voor popcorn. Veel plezier met samen kijken!",
|
||||
"ERR_CONN_TIMEOUT": "Verbinding time-out. Probeer het opnieuw.",
|
||||
"ERR_INVALID_SERVER_URL": "Ongeldig server URL-formaat.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.",
|
||||
"ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.",
|
||||
"ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.",
|
||||
"ERR_SELECT_VIDEO": "Selecteer eerst een video!",
|
||||
"TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!",
|
||||
"TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt",
|
||||
"TOAST_LOBBY_SKIPPED": "Episode Lobby overgeslagen.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Lobby overslaan mislukt.",
|
||||
"TOAST_LOGS_COPIED": "Gekopieerd!",
|
||||
"TOAST_PEER_JOINED": "{name} neemt deel aan de kamer",
|
||||
"TOAST_PEER_LEFT": "{name} heeft de kamer verlaten",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Verbonden",
|
||||
"STATUS_RECONNECTING": "Opnieuw verbinden...",
|
||||
"STATUS_CONNECTING": "Verbinden...",
|
||||
"STATUS_FAILED": "Mislukt",
|
||||
"STATUS_DISCONNECTED": "Verbinding verbroken",
|
||||
"BTN_STATE_JOINING": "🚀 Deelnemen...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Opnieuw verbinden...",
|
||||
"BTN_STATE_PLAYING": "▶ Afspelen...",
|
||||
"BTN_STATE_PAUSING": "⏸ Pauzeren...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Synchroniseren met groep ({time})...",
|
||||
"BTN_STATE_SYNCING": "Synchroniseren...",
|
||||
"BTN_STATE_SYNCED": "✅ Gesynchroniseerd!",
|
||||
"NOTIF_PLAY": "is begonnen met afspelen",
|
||||
"NOTIF_PAUSE": "heeft afspelen gepauzeerd",
|
||||
"NOTIF_SEEK": "heeft de video gespoeld",
|
||||
"NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart",
|
||||
"NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd",
|
||||
"DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.",
|
||||
"DEBUG_COMM_FAIL": "Kon niet cmmuniceren met de videotab.",
|
||||
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
|
||||
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
|
||||
"EMPTY_HISTORY_TITLE": "Nog geen activiteit",
|
||||
"EMPTY_HISTORY_HINT": "Speel af, pauzeer of spoel om geschiedenis te zien",
|
||||
"EMPTY_LOGS_TITLE": "Geen logs",
|
||||
"EMPTY_LOGS_HINT": "Verbindingsgebeurtenissen verschijnen hier",
|
||||
"EMPTY_ROOMS_TITLE": "Geen actieve kamers",
|
||||
"EMPTY_ROOMS_HINT": "Maak een kamer of vernieuw om openbare te vinden",
|
||||
"LABEL_YOU": "Jij",
|
||||
"ONBOARDING_DONE": "Klaar!",
|
||||
"LABEL_LOBBY_PEER_READY": "Gereed",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Laden...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Wachtwoord beveiligd",
|
||||
"LABEL_PEERS_COUNT": "{count} deelnemers",
|
||||
"LABEL_CUSTOM_SERVER": "Aangepaste server",
|
||||
"BTN_STATE_CREATING": "🚀 Kamer maken...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Episode Sync mislukt",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync geannuleerd: {reason}. Mogelijk moet u handmatig synchroniseren.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Time-out",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Time-out (hersteld)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Alle andere deelnemers zijn vertrokken",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Time-out — niet alle deelnemers hebben de aflevering geladen",
|
||||
"LOBBY_CANCEL_USER": "Geannuleerd door gebruiker",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync-fout",
|
||||
"FOOTER_SUPPORT": "☕ Trakteer op een koffie",
|
||||
"FOOTER_REVIEW": "★ Beoordelen",
|
||||
"FOOTER_SUPPORT_PROMPT": "Vind je KoalaSync leuk? Laat een review achter!",
|
||||
"LABEL_AUDIO_PROCESSING": "Audioverwerking",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Past audio-effecten zoals compressie toe op video-afspelen",
|
||||
"AUDIO_OPEN_SETTINGS": "Openen",
|
||||
"NEW_FEATURE_AUDIO": "Nieuw: Audioverwerking — probeer de compressor!",
|
||||
"AUDIO_BACK": "← Terug",
|
||||
"AUDIO_PAGE_TITLE": "Audio-instellingen",
|
||||
"AUDIO_MASTER_TOGGLE": "Audioverwerking",
|
||||
"AUDIO_COMPRESSOR": "Compressor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Ingeschakeld",
|
||||
"AUDIO_PRESET": "Voorinstelling",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Aanbevolen",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Dynamisch bereik",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Stemverbetering",
|
||||
"AUDIO_PRESET_SMOOTH": "Zacht",
|
||||
"AUDIO_PRESET_CUSTOM": "Aangepast",
|
||||
"AUDIO_PARAM_THRESHOLD": "Drempel",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizer",
|
||||
"AUDIO_COMING_SOON": "Binnenkort beschikbaar"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"LANG_CODE": "pl",
|
||||
"HTML_CLASS": "lang-pl",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Pokój",
|
||||
"TAB_ROOM_TOOLTIP": "Ustawienia pokoju i połączenie",
|
||||
"TAB_SYNC": "Sync",
|
||||
"TAB_SYNC_TOOLTIP": "Sterowanie synchronizacją wideo i zdalne akcje",
|
||||
"TAB_SETTINGS": "Ustawienia",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferencje rozszerzenia",
|
||||
"TAB_STATUS": "Status",
|
||||
"TAB_STATUS_TOOLTIP": "Zaawansowana diagnostyka i logi",
|
||||
"BTN_CREATE_ROOM": "+ Utwórz nowy pokój",
|
||||
"MANUAL_CONNECT_HEADER": "Połączenie ręczne / Zaawansowane",
|
||||
"LABEL_SERVER": "Serwer",
|
||||
"BTN_SERVER_OFFICIAL": "Oficjalny",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Użyj oficjalnego, niezawodnego serwera",
|
||||
"BTN_SERVER_CUSTOM": "Własny",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Połącz się z własnym serwerem",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://twoj-serwer:3000",
|
||||
"LABEL_ROOM_ID": "ID pokoju",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "Unikalny identyfikator pokoju synchronizacji",
|
||||
"PLACEHOLDER_ROOM_ID": "Wpisz ID pokoju",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Unikalny identyfikator pokoju, do którego chcesz dołączyć",
|
||||
"LABEL_PASSWORD": "Hasło (opcjonalnie)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Opcjonalne hasło ograniczające dostęp do pokoju",
|
||||
"PLACEHOLDER_PASSWORD": "Hasło pokoju (opcjonalnie)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Hasło do pokoju (pozostaw puste, jeśli brak)",
|
||||
"BTN_JOIN_ROOM": "Dołącz / Utwórz pokój",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Połącz z pokojem",
|
||||
"LABEL_PUBLIC_ROOMS": "Pokoje publiczne",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista publicznie dostępnych pokoi na tym serwerze",
|
||||
"BTN_REFRESH": "ODŚWIEŻ",
|
||||
"BTN_REFRESH_TOOLTIP": "Odśwież listę pokoi publicznych",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Odświeżanie...",
|
||||
"BTN_REFRESH_COOLDOWN": "CZEKAJ {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Odświeżanie listy pokoi stygnie. Spróbuj ponownie za {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Odświeżanie pokoi publicznych. Następne odświeżenie za {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Aktywny pokój",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Pokój, z którym jesteś aktualnie połączony",
|
||||
"ACTIVE_ROOM_NONE": "BRAK",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Oficjalny serwer",
|
||||
"LABEL_INVITE_LINK": "Link do zaproszenia",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Udostępnij ten link znajomym, aby mogli dołączyć",
|
||||
"LABEL_PEERS_IN_ROOM": "Uczestnicy w pokoju",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Inni użytkownicy aktualnie połączeni z tym pokojem",
|
||||
"NO_PEERS_CONNECTED": "Brak połączonych uczestników",
|
||||
"BTN_LEAVE_ROOM": "Opuść pokój",
|
||||
"LABEL_SELECT_VIDEO": "Wybierz wideo",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Wybierz kartę przeglądarki zawierającą wideo do synchronizacji",
|
||||
"OPTION_SELECT_TAB": "-- Wybierz kartę --",
|
||||
"LABEL_REMOTE_CONTROL": "Zdalne sterowanie",
|
||||
"BTN_COPY_INVITE": "📋 Link do zaproszenia",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Kopiuj link zaproszenia",
|
||||
"BTN_PLAY": "▶ Odtwórz",
|
||||
"BTN_PLAY_TOOLTIP": "Wyślij polecenie odtwarzania do wszystkich",
|
||||
"BTN_PAUSE": "⏸ Pauza",
|
||||
"BTN_PAUSE_TOOLTIP": "Wyślij polecenie pauzy do wszystkich",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Wymuś synchronizację wszystkich użytkowników",
|
||||
"OPTION_JUMP_TO_OTHERS": "Skocz do innych",
|
||||
"OPTION_JUMP_TO_ME": "Skocz do mnie",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Wybierz cel synchronizacji",
|
||||
"LABEL_LAST_ACTIVITY": "Status ostatniej aktywności",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Pokazuje najnowsze polecenie odtwarzania, pauzy lub przewijania",
|
||||
"NO_RECENT_COMMANDS": "Brak ostatnich poleceń",
|
||||
"LOBBY_HEADER": "LOBBY ODCINKA",
|
||||
"LOBBY_WAITING_FOR": "🎬 Czekam na: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Oczekiwanie na uczestników...",
|
||||
"BTN_SKIP_PLAY": "Pomiń i odtwórz mimo to",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Anuluj lobby i odtwórz mimo to",
|
||||
"LOBBY_CONNECT_FIRST": "Najpierw połącz się z pokojem",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Musisz dołączyć do pokoju przez link zaproszenia lub utworzyć nowy, aby synchronizować wideo.",
|
||||
"BTN_CREATE_ROOM_ALT": "Utwórz nowy pokój",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Utwórz nowy losowy pokój i dołącz",
|
||||
"LABEL_USERNAME": "Twoja nazwa użytkownika",
|
||||
"LABEL_USERNAME_TOOLTIP": "Nazwa użytkownika pomaga innym Cię zidentyfikować.",
|
||||
"PLACEHOLDER_USERNAME": "Anonimowy Koala",
|
||||
"LABEL_HIDE_CLUTTER": "Ukryj zbędne karty",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtruje karty bez wideo i niepowiązane domeny, aby zachować porządek",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Auto-sync następnego odcinka",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Automatycznie wstrzymuje i czeka na wszystkich uczestników po zmianie odcinka, a następnie uruchamia się wspólnie.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Auto-kopiowanie zaproszenia",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Automatycznie kopiuje link zaproszenia do schowka po utworzeniu nowego pokoju.",
|
||||
"LABEL_NOTIFICATIONS": "Powiadomienia przeglądarki",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Pokazuje natywne powiadomienia systemowe, gdy ktoś dołącza/odchodzi lub odtwarza/wstrzymuje.",
|
||||
"LABEL_LANGUAGE": "Język aplikacji",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Wybierz preferowany język rozszerzenia",
|
||||
"LABEL_TROUBLESHOOTING": "Rozwiązywanie problemów",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Narzędzia do rozwiązywania problemów z połączeniem",
|
||||
"BTN_REGEN_ID": "Wygeneruj ponownie ID",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Wygeneruj ponownie swój identyfikator i połącz się ponownie",
|
||||
"REGEN_ID_DESC": "Użyj tego, jeśli widzisz błędy o zduplikowanej tożsamości.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Inny problem? Otwórz Issue na GitHub",
|
||||
"LABEL_CONN_STATUS": "Status połączenia",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Bieżący stan połączenia WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Rozłączono",
|
||||
"BTN_RETRY": "PONÓW",
|
||||
"BTN_RETRY_TOOLTIP": "Próba ponownego połączenia z serwerem",
|
||||
"BTN_COPY_LOGS": "Kopiuj logi",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Kopiuj logi do schowka, aby je udostępnić",
|
||||
"LABEL_VIDEO_DEBUG": "Info debugowania wideo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Szczegóły techniczne wybranego elementu wideo",
|
||||
"VIDEO_DEBUG_EMPTY": "Nie wybrano karty ani nie wykryto wideo.",
|
||||
"LABEL_HISTORY": "Pełna historia akcji",
|
||||
"LABEL_HISTORY_TOOLTIP": "Chronologiczny rejestr wszystkich poleceń synchronizacji w pokoju",
|
||||
"HISTORY_EMPTY": "Brak aktywności",
|
||||
"LABEL_LOGS": "Logi (ostatnie 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Logi połączenia technicznego do debugowania",
|
||||
"BTN_CLEAR": "WYCZYŚĆ",
|
||||
"BTN_CLEAR_TOOLTIP": "Wyczyść dane wyjściowe logów",
|
||||
"LABEL_GITHUB": "Repozytorium GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Pomiń",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Pomiń samouczek",
|
||||
"BTN_ONBOARDING_NEXT": "Dalej",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Przejdź do następnego kroku",
|
||||
"ONBOARDING_1_TITLE": "Witaj w KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Oglądaj filmy w idealnej synchronizacji — bez względu na to, gdzie jesteś. Zróbmy szybką wycieczkę!",
|
||||
"ONBOARDING_2_TITLE": "1. Utwórz pokój",
|
||||
"ONBOARDING_2_TEXT": "Zacznij tutaj. Utwórz pokój i udostępnij link zaproszenia znajomym.",
|
||||
"ONBOARDING_3_TITLE": "2. Wybierz wideo",
|
||||
"ONBOARDING_3_TEXT": "Przejdź tutaj, aby wybrać wideo, które chcesz synchronizować. Odtwarzaj, wstrzymuj i przewijaj — każdy pozostaje zsynchronizowany.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizacja",
|
||||
"ONBOARDING_4_TEXT": "Wybierz fajną nazwę użytkownika, aby znajomi wiedzieli, kim jesteś.",
|
||||
"ONBOARDING_5_TITLE": "Wszystko gotowe!",
|
||||
"ONBOARDING_5_TEXT": "Czas na popcorn. Miłego wspólnego oglądania!",
|
||||
"ERR_CONN_TIMEOUT": "Przekroczono limit czasu połączenia. Spróbuj ponownie.",
|
||||
"ERR_INVALID_SERVER_URL": "Nieprawidłowy format URL serwera.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.",
|
||||
"ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.",
|
||||
"ERR_SELECT_VIDEO": "Najpierw wybierz wideo!",
|
||||
"TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!",
|
||||
"TOAST_COPY_FAILED": "Nie udało się skopiować do schowka",
|
||||
"TOAST_LOBBY_SKIPPED": "Pominięto lobby odcinka.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Nie udało się pominąć lobby.",
|
||||
"TOAST_LOGS_COPIED": "Skopiowano!",
|
||||
"TOAST_PEER_JOINED": "{name} dołączył do pokoju",
|
||||
"TOAST_PEER_LEFT": "{name} opuścił pokój",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Połączono",
|
||||
"STATUS_RECONNECTING": "Ponowne łączenie...",
|
||||
"STATUS_CONNECTING": "Łączenie...",
|
||||
"STATUS_FAILED": "Nieudane",
|
||||
"STATUS_DISCONNECTED": "Rozłączono",
|
||||
"BTN_STATE_JOINING": "🚀 Dołączanie...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Ponowne łączenie...",
|
||||
"BTN_STATE_PLAYING": "▶ Odtwarzanie...",
|
||||
"BTN_STATE_PAUSING": "⏸ Wstrzymywanie...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Synchronizacja do grupy ({time})...",
|
||||
"BTN_STATE_SYNCING": "Synchronizacja...",
|
||||
"BTN_STATE_SYNCED": "✅ Zsynchronizowano!",
|
||||
"NOTIF_PLAY": "rozpoczął odtwarzanie",
|
||||
"NOTIF_PAUSE": "wstrzymał odtwarzanie",
|
||||
"NOTIF_SEEK": "przewinął wideo",
|
||||
"NOTIF_FORCE_PREPARE": "wymusił synchronizację",
|
||||
"NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich",
|
||||
"DEBUG_NO_TAB": "Nie wybrano karty docelowej.",
|
||||
"DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.",
|
||||
"EMPTY_PEERS_TITLE": "Brak uczestników",
|
||||
"EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć",
|
||||
"EMPTY_HISTORY_TITLE": "Brak aktywności",
|
||||
"EMPTY_HISTORY_HINT": "Odtwórz, wstrzymaj lub przewiń, aby zobaczyć historię",
|
||||
"EMPTY_LOGS_TITLE": "Brak logów",
|
||||
"EMPTY_LOGS_HINT": "Zdarzenia połączenia pojawią się tutaj",
|
||||
"EMPTY_ROOMS_TITLE": "Brak aktywnych pokoi",
|
||||
"EMPTY_ROOMS_HINT": "Utwórz pokój lub odśwież, aby znaleźć publiczne",
|
||||
"LABEL_YOU": "Ty",
|
||||
"ONBOARDING_DONE": "Gotowe!",
|
||||
"LABEL_LOBBY_PEER_READY": "Gotowy",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Ładowanie...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Chronione hasłem",
|
||||
"LABEL_PEERS_COUNT": "{count} uczestników",
|
||||
"LABEL_CUSTOM_SERVER": "Własny serwer",
|
||||
"BTN_STATE_CREATING": "🚀 Tworzenie pokoju...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Synchronizacja odcinka nieudana",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync anulowane: {reason}. Może być konieczna ręczna synchronizacja.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Limit czasu",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Limit czasu (przywrócono)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Wszyscy inni uczestnicy wyszli",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Limit czasu — nie wszyscy uczestnicy załadowali odcinek",
|
||||
"LOBBY_CANCEL_USER": "Anulowane przez użytkownika",
|
||||
"NOTIF_ERROR_TITLE": "Błąd KoalaSync",
|
||||
"FOOTER_SUPPORT": "☕ Postaw kawę",
|
||||
"FOOTER_REVIEW": "★ Oceń",
|
||||
"FOOTER_SUPPORT_PROMPT": "Podoba Ci się KoalaSync? Zostaw recenzję!",
|
||||
"LABEL_AUDIO_PROCESSING": "Przetwarzanie dźwięku",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Stosuje efekty dźwiękowe, takie jak kompresja, do odtwarzania wideo",
|
||||
"AUDIO_OPEN_SETTINGS": "Otwórz",
|
||||
"NEW_FEATURE_AUDIO": "Nowość: Przetwarzanie dźwięku — wypróbuj kompresor!",
|
||||
"AUDIO_BACK": "← Wstecz",
|
||||
"AUDIO_PAGE_TITLE": "Ustawienia dźwięku",
|
||||
"AUDIO_MASTER_TOGGLE": "Przetwarzanie dźwięku",
|
||||
"AUDIO_COMPRESSOR": "Kompresor",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Włączony",
|
||||
"AUDIO_PRESET": "Preset",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Zalecany",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Zakres dynamiki",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Wzmocnienie głosu",
|
||||
"AUDIO_PRESET_SMOOTH": "Płynny",
|
||||
"AUDIO_PRESET_CUSTOM": "Niestandardowy",
|
||||
"AUDIO_PARAM_THRESHOLD": "Próg",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Korektor",
|
||||
"AUDIO_COMING_SOON": "Wkrótce"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Скоро"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"LANG_CODE": "tr",
|
||||
"HTML_CLASS": "lang-tr",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Oda",
|
||||
"TAB_ROOM_TOOLTIP": "Oda ayarları ve bağlantı",
|
||||
"TAB_SYNC": "Senkronizasyon",
|
||||
"TAB_SYNC_TOOLTIP": "Video senkronizasyon kontrolleri ve uzaktan işlemler",
|
||||
"TAB_SETTINGS": "Ayarlar",
|
||||
"TAB_SETTINGS_TOOLTIP": "Eklenti tercihleri",
|
||||
"TAB_STATUS": "Durum",
|
||||
"TAB_STATUS_TOOLTIP": "Gelişmiş Teşhis ve Günlükler",
|
||||
"BTN_CREATE_ROOM": "+ Yeni Oda Oluştur",
|
||||
"MANUAL_CONNECT_HEADER": "Manuel Bağlantı / Gelişmiş",
|
||||
"LABEL_SERVER": "Sunucu",
|
||||
"BTN_SERVER_OFFICIAL": "Resmi",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Resmi güvenilir sunucuyu kullanın",
|
||||
"BTN_SERVER_CUSTOM": "Özel",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Kendi barındırdığınız sunucuya bağlanın",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://sunucunuz:3000",
|
||||
"LABEL_ROOM_ID": "Oda Kimliği",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "Senkronizasyon odanız için benzersiz kimlik",
|
||||
"PLACEHOLDER_ROOM_ID": "Oda Kimliğini Girin",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Katılmak istediğiniz odanın benzersiz kimliği",
|
||||
"LABEL_PASSWORD": "Şifre (İsteğe Bağlı)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Oda erişimini kısıtlamak için isteğe bağlı şifre",
|
||||
"PLACEHOLDER_PASSWORD": "Oda Şifresi (isteğe bağlı)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Oda şifresi (yoksa boş bırakın)",
|
||||
"BTN_JOIN_ROOM": "Odaya Katıl / Oluştur",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Odaya bağlan",
|
||||
"LABEL_PUBLIC_ROOMS": "Açık Odalar",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Bu sunucuda herkese açık odaların listesi",
|
||||
"BTN_REFRESH": "YENİLE",
|
||||
"BTN_REFRESH_TOOLTIP": "Genel odaların listesini yenile",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Yenileniyor...",
|
||||
"BTN_REFRESH_COOLDOWN": "{seconds}sn BEKLE",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Oda listesi yenileme soğuyor. {seconds}sn sonra tekrar deneyin.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Açık odalar yenileniyor. Bir sonraki yenileme {seconds}sn sonra yapılabilecek.",
|
||||
"LABEL_ACTIVE_ROOM": "Aktif Oda",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Şu anda bağlı olduğunuz oda",
|
||||
"ACTIVE_ROOM_NONE": "YOK",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Resmi Sunucu",
|
||||
"LABEL_INVITE_LINK": "Davet Linki",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Katılabilmeleri için bu bağlantıyı arkadaşlarınızla paylaşın",
|
||||
"LABEL_PEERS_IN_ROOM": "Odadaki Kişiler",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Şu anda bu odaya bağlı olan diğer kullanıcılar",
|
||||
"NO_PEERS_CONNECTED": "Bağlı kimse yok",
|
||||
"BTN_LEAVE_ROOM": "Odadan Ayrıl",
|
||||
"LABEL_SELECT_VIDEO": "Video Seç",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Senkronize edilecek videoyu içeren tarayıcı sekmesini seçin",
|
||||
"OPTION_SELECT_TAB": "-- Bir Sekme Seçin --",
|
||||
"LABEL_REMOTE_CONTROL": "Uzaktan Kumanda",
|
||||
"BTN_COPY_INVITE": "📋 Davet Linki",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Davet Linkini Kopyala",
|
||||
"BTN_PLAY": "▶ Oynat",
|
||||
"BTN_PLAY_TOOLTIP": "Herkese Oynat komutu gönder",
|
||||
"BTN_PAUSE": "⏸ Duraklat",
|
||||
"BTN_PAUSE_TOOLTIP": "Herkese Duraklat komutu gönder",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Tüm kullanıcıları senkronize etmeye zorla",
|
||||
"OPTION_JUMP_TO_OTHERS": "Diğerlerine Atla",
|
||||
"OPTION_JUMP_TO_ME": "Bana Atla",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Senkronizasyon hedefini seçin",
|
||||
"LABEL_LAST_ACTIVITY": "Son Etkinlik Durumu",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "En son oynatma, duraklatma veya arama komutunu gösterir",
|
||||
"NO_RECENT_COMMANDS": "Son komut yok",
|
||||
"LOBBY_HEADER": "BÖLÜM LOBİSİ",
|
||||
"LOBBY_WAITING_FOR": "🎬 Beklenen: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Bağlantılar bekleniyor...",
|
||||
"BTN_SKIP_PLAY": "Atla ve Yine de Oynat",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Lobiyi iptal et ve yine de oynat",
|
||||
"LOBBY_CONNECT_FIRST": "Önce bir odaya bağlanın",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Videoları senkronize etmek için bir davet bağlantısı aracılığıyla bir odaya katılmanız veya yeni bir oda oluşturmanız gerekir.",
|
||||
"BTN_CREATE_ROOM_ALT": "Yeni Oda Oluştur",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Yeni rastgele bir oda oluşturun ve katılın",
|
||||
"LABEL_USERNAME": "Kullanıcı Adınız",
|
||||
"LABEL_USERNAME_TOOLTIP": "Kullanıcı adı başkalarının sizi tanımasına yardımcı olur.",
|
||||
"PLACEHOLDER_USERNAME": "Anonim Koala",
|
||||
"LABEL_HIDE_CLUTTER": "Gereksiz Sekmeleri Gizle",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Listeyi temiz tutmak için video olmayan sekmeleri ve alakasız alan adlarını filtreler",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sonraki Bölümü Otomatik Eşitle",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Bir bölüm değiştiğinde otomatik olarak duraklatılır ve tüm bağlantıların hazır olmasını bekler, ardından birlikte senkronize olarak başlar.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Davet Linkini Otomatik Kopyala",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Yeni bir oda oluşturulduğunda davet bağlantısını panoya otomatik olarak kopyalar.",
|
||||
"LABEL_NOTIFICATIONS": "Tarayıcı Bildirimleri",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Biri katıldığında/ayrıldığında veya oynattığında/duraklattığında yerel sistem bildirimlerini gösterir.",
|
||||
"LABEL_LANGUAGE": "Uygulama Dili",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Tercih ettiğiniz eklenti dilini seçin",
|
||||
"LABEL_TROUBLESHOOTING": "Sorun Giderme",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Bağlantı sorunlarını giderme araçları",
|
||||
"BTN_REGEN_ID": "Bağlantı Kimliğini Yeniden Oluştur",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Dahili kimliğinizi yeniden oluşturun ve tekrar bağlanın",
|
||||
"REGEN_ID_DESC": "\"Duplicate Identity\" (Mükerrer Kimlik) hataları görüyorsanız bunu kullanın.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Başka bir sorun? GitHub Issue açın",
|
||||
"LABEL_CONN_STATUS": "Bağlantı Durumu",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Mevcut WebSocket bağlantı durumu",
|
||||
"CONN_STATUS_DISCONNECTED": "Bağlantı Kesildi",
|
||||
"BTN_RETRY": "TEKRAR DENE",
|
||||
"BTN_RETRY_TOOLTIP": "Sunucuya yeniden bağlanmayı dene",
|
||||
"BTN_COPY_LOGS": "Günlükleri Kopyala",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Paylaşmak için günlükleri panoya kopyalayın",
|
||||
"LABEL_VIDEO_DEBUG": "Video Hata Ayıklama Bilgisi",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Şu anda seçili video öğesi hakkında teknik ayrıntılar",
|
||||
"VIDEO_DEBUG_EMPTY": "Sekme seçilmedi veya video algılanmadı.",
|
||||
"LABEL_HISTORY": "Tam İşlem Geçmişi",
|
||||
"LABEL_HISTORY_TOOLTIP": "Odadaki tüm senkronizasyon komutlarının kronolojik günlüğü",
|
||||
"HISTORY_EMPTY": "Henüz etkinlik yok",
|
||||
"LABEL_LOGS": "Günlükler (Son 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Hata ayıklama için teknik bağlantı günlükleri",
|
||||
"BTN_CLEAR": "TEMİZLE",
|
||||
"BTN_CLEAR_TOOLTIP": "Günlük çıktısını temizle",
|
||||
"LABEL_GITHUB": "GitHub Deposu",
|
||||
"BTN_ONBOARDING_SKIP": "Atla",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Eğitimi atla",
|
||||
"BTN_ONBOARDING_NEXT": "İleri",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Bir sonraki adıma git",
|
||||
"ONBOARDING_1_TITLE": "KoalaSync'e Hoş Geldiniz!",
|
||||
"ONBOARDING_1_TEXT": "Nerede olursanız olun videoları mükemmel bir senkronizasyonla birlikte izleyin. Hızlı bir tura çıkalım!",
|
||||
"ONBOARDING_2_TITLE": "1. Oda Oluştur",
|
||||
"ONBOARDING_2_TEXT": "Buradan başlayın. Bir oda oluşturun ve davet bağlantısını arkadaşlarınızla paylaşın.",
|
||||
"ONBOARDING_3_TITLE": "2. Video Seç",
|
||||
"ONBOARDING_3_TEXT": "Senkronize etmek istediğiniz videoyu seçmek için buraya gidin. Oynatın, duraklatın ve arayın — herkes senkronize kalır.",
|
||||
"ONBOARDING_4_TITLE": "3. Kişiselleştir",
|
||||
"ONBOARDING_4_TEXT": "Arkadaşlarınızın kim olduğunuzu bilmesi için eğlenceli bir kullanıcı adı seçin.",
|
||||
"ONBOARDING_5_TITLE": "Her şey hazır!",
|
||||
"ONBOARDING_5_TEXT": "Patlamış mısır alma zamanı. Birlikte izlemenin keyfini çıkarın!",
|
||||
"ERR_CONN_TIMEOUT": "Bağlantı zaman aşımına uğradı. Lütfen tekrar deneyin.",
|
||||
"ERR_INVALID_SERVER_URL": "Geçersiz Sunucu URL formatı.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Kimlik henüz yüklenmedi. Biraz bekleyin ve tekrar deneyin.",
|
||||
"ERR_NO_PEERS_TIME": "Bilinen bir zamanı olan başka bağlantı yok. 'Bana Atla' seçeneğine geçin.",
|
||||
"ERR_NO_VIDEO_TAB": "Video sekmesine bağlanılamadı.",
|
||||
"ERR_SELECT_VIDEO": "Lütfen önce bir video seçin!",
|
||||
"TOAST_INVITE_COPIED": "Davet bağlantısı kopyalandı!",
|
||||
"TOAST_COPY_FAILED": "Panoya kopyalanamadı",
|
||||
"TOAST_LOBBY_SKIPPED": "Bölüm Lobisi atlandı.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Lobi atlanamadı.",
|
||||
"TOAST_LOGS_COPIED": "Kopyalandı!",
|
||||
"TOAST_PEER_JOINED": "{name} odaya katıldı",
|
||||
"TOAST_PEER_LEFT": "{name} odadan ayrıldı",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Bağlandı",
|
||||
"STATUS_RECONNECTING": "Yeniden bağlanılıyor...",
|
||||
"STATUS_CONNECTING": "Bağlanılıyor...",
|
||||
"STATUS_FAILED": "Başarısız",
|
||||
"STATUS_DISCONNECTED": "Bağlantı Kesildi",
|
||||
"BTN_STATE_JOINING": "🚀 Katılınıyor...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Yeniden bağlanılıyor...",
|
||||
"BTN_STATE_PLAYING": "▶ Oynatılıyor...",
|
||||
"BTN_STATE_PAUSING": "⏸ Duraklatılıyor...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Gruba eşitleniyor ({time})...",
|
||||
"BTN_STATE_SYNCING": "Senkronize ediliyor...",
|
||||
"BTN_STATE_SYNCED": "✅ Eşitlendi!",
|
||||
"NOTIF_PLAY": "oynatmayı başlattı",
|
||||
"NOTIF_PAUSE": "oynatmayı duraklattı",
|
||||
"NOTIF_SEEK": "videoda arama yaptı",
|
||||
"NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı",
|
||||
"NOTIF_FORCE_EXECUTE": "herkesi eşitledi",
|
||||
"DEBUG_NO_TAB": "Hedef sekme seçilmedi.",
|
||||
"DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.",
|
||||
"EMPTY_PEERS_TITLE": "Henüz kimse yok",
|
||||
"EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın",
|
||||
"EMPTY_HISTORY_TITLE": "Henüz etkinlik yok",
|
||||
"EMPTY_HISTORY_HINT": "Geçmişi görmek için oynatın, duraklatın veya arayın",
|
||||
"EMPTY_LOGS_TITLE": "Günlük yok",
|
||||
"EMPTY_LOGS_HINT": "Bağlantı olayları burada görünecektir",
|
||||
"EMPTY_ROOMS_TITLE": "Aktif oda yok",
|
||||
"EMPTY_ROOMS_HINT": "Bir oda oluşturun veya genel odaları bulmak için yenileyin",
|
||||
"LABEL_YOU": "Siz",
|
||||
"ONBOARDING_DONE": "Tamamlandı!",
|
||||
"LABEL_LOBBY_PEER_READY": "Hazır",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Yükleniyor...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Şifre Korumalı",
|
||||
"LABEL_PEERS_COUNT": "{count} kişi",
|
||||
"LABEL_CUSTOM_SERVER": "Özel Sunucu",
|
||||
"BTN_STATE_CREATING": "🚀 Oda Oluşturuluyor...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Bölüm Eşitlemesi Başarısız",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Otomatik eşitleme iptal edildi: {reason}. Manuel olarak eşitlemeniz gerekebilir.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Zaman aşımı",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Zaman aşımı (kurtarıldı)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Diğer tüm bağlantılar ayrıldı",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zaman aşımı — tüm bağlantılar bölümü yüklemedi",
|
||||
"LOBBY_CANCEL_USER": "Kullanıcı tarafından iptal edildi",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync Hatası",
|
||||
"FOOTER_SUPPORT": "☕ Bana kahve ısmarla",
|
||||
"FOOTER_REVIEW": "★ Değerlendir",
|
||||
"FOOTER_SUPPORT_PROMPT": "KoalaSync'i beğendin mi? Bir yorum bırak!",
|
||||
"LABEL_AUDIO_PROCESSING": "Ses işleme",
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Video oynatımına sıkıştırma gibi ses efektleri uygular",
|
||||
"AUDIO_OPEN_SETTINGS": "Aç",
|
||||
"NEW_FEATURE_AUDIO": "Yeni: Ses işleme — kompresörü deneyin!",
|
||||
"AUDIO_BACK": "← Geri",
|
||||
"AUDIO_PAGE_TITLE": "Ses ayarları",
|
||||
"AUDIO_MASTER_TOGGLE": "Ses işleme",
|
||||
"AUDIO_COMPRESSOR": "Kompresör",
|
||||
"AUDIO_COMPRESSOR_ENABLE": "Etkin",
|
||||
"AUDIO_PRESET": "Ön ayar",
|
||||
"AUDIO_PRESET_RECOMMENDED": "Önerilen",
|
||||
"AUDIO_PRESET_DYNAMIC_RANGE": "Dinamik aralık",
|
||||
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Ses iyileştirme",
|
||||
"AUDIO_PRESET_SMOOTH": "Yumuşak",
|
||||
"AUDIO_PRESET_CUSTOM": "Özel",
|
||||
"AUDIO_PARAM_THRESHOLD": "Eşik",
|
||||
"AUDIO_PARAM_KNEE": "Knee",
|
||||
"AUDIO_PARAM_RATIO": "Ratio",
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Ekolayzer",
|
||||
"AUDIO_COMING_SOON": "Çok yakında"
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.0.7",
|
||||
"version": "2.2.2",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
<meta charset="utf-8">
|
||||
<title>KoalaSync</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
src: url('assets/TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
@@ -23,7 +30,7 @@
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -127,7 +134,7 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input, select {
|
||||
input, select, option {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--card);
|
||||
@@ -139,6 +146,20 @@
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
#langSelector,
|
||||
#langSelector::picker(select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
#langSelector::picker(select) {
|
||||
background-color: var(--bg);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
font-family: 'Twemoji Country Flags', inherit;
|
||||
}
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
@@ -310,6 +331,39 @@
|
||||
transform: translateX(16px);
|
||||
background-color: white;
|
||||
}
|
||||
.popup-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
padding-top: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.popup-footer a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s, opacity 0.2s;
|
||||
}
|
||||
.popup-footer a:hover {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.feature-hint {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
margin-left: 4px;
|
||||
animation: featurePulse 2s ease-in-out infinite;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes featurePulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.3); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -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>
|
||||
|
||||
@@ -520,8 +598,9 @@
|
||||
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
|
||||
<span id="connDot" class="status-dot status-offline"></span>
|
||||
<span id="connText" style="flex:1;">Disconnected</span>
|
||||
<span 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,15 +220,18 @@ 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();
|
||||
|
||||
@@ -171,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);
|
||||
@@ -574,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;
|
||||
@@ -735,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';
|
||||
}
|
||||
@@ -754,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 = '';
|
||||
@@ -882,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)';
|
||||
@@ -899,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' });
|
||||
}
|
||||
@@ -913,43 +1012,57 @@ 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) => {
|
||||
@@ -962,14 +1075,14 @@ if (elements.langSelector) {
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
const data = await chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
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.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
await populateTabs();
|
||||
}
|
||||
@@ -985,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' });
|
||||
@@ -1099,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
|
||||
@@ -1112,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 = [];
|
||||
@@ -1381,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') {
|
||||
@@ -1467,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') {
|
||||
@@ -1487,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') {
|
||||
@@ -1501,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 {
|
||||
@@ -1593,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 : [];
|
||||
@@ -1656,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;
|
||||
@@ -1675,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;
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.0.7",
|
||||
"version": "2.2.2",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -54,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
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
@@ -192,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();
|
||||
@@ -228,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) {
|
||||
@@ -237,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) {
|
||||
@@ -246,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) {
|
||||
@@ -255,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -585,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,
|
||||
@@ -613,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;
|
||||
}
|
||||
@@ -657,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': '🦩',
|
||||
|
||||
@@ -28,7 +28,7 @@ Caddy is the recommended web server. It provides automatic HTTPS and high-perfor
|
||||
|
||||
### Recommended Caddyfile
|
||||
|
||||
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../Caddyfile.example).
|
||||
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../examples/Caddyfile.example).
|
||||
|
||||
```caddy
|
||||
sync.koalastuff.net {
|
||||
|
||||
@@ -24,12 +24,19 @@ We divide supported languages into two tiers: **Core Languages** (fully hand-cra
|
||||
|
||||
| Language Code | Language Name | Verification Status | Rationale / Context |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `en` | **English** | `100% Manually Verified` | Primary developer language and system default |
|
||||
| `de` | **German** | `100% Manually Verified` | Core market and compliance baseline |
|
||||
| `fr` | **French** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `es` | **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt-BR` | **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ru` | **Russian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `en` | 🇬🇧 **English** | `100% Manually Verified` | Primary developer language and system default |
|
||||
| `de` | 🇩🇪 **German** | `100% Manually Verified` | Core market and compliance baseline |
|
||||
| `fr` | 🇫🇷 **French** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `es` | 🇪🇸 **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt-BR` | 🇧🇷 **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ru` | 🇷🇺 **Russian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `it` | 🇮🇹 **Italian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pl` | 🇵🇱 **Polish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `tr` | 🇹🇷 **Turkish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `nl` | 🇳🇱 **Dutch** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ja` | 🇯🇵 **Japanese** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ko` | 🇰🇷 **Korean** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt` | 🇵🇹 **European Portuguese** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
|
||||
> [!WARNING]
|
||||
> **Autogeneration Quality Rule**
|
||||
@@ -155,12 +162,19 @@ In **v2.0**, we extended full internationalization support to the **Browser Exte
|
||||
|
||||
* **Locales Directory:** [`extension/locales/`](file:///Users/koala/Documents/KoalaPlay/extension/locales/)
|
||||
* **Active Dictionaries:**
|
||||
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (Baseline English)
|
||||
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (German)
|
||||
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (French)
|
||||
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (Spanish)
|
||||
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (Portuguese (Brasil))
|
||||
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (Russian)
|
||||
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (🇬🇧 Baseline English)
|
||||
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (🇩🇪 German)
|
||||
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (🇫🇷 French)
|
||||
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (🇪🇸 Spanish)
|
||||
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (🇧🇷 Portuguese (Brasil))
|
||||
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (🇷🇺 Russian)
|
||||
* [`it.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/it.json) (🇮🇹 Italian)
|
||||
* [`pl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pl.json) (🇵🇱 Polish)
|
||||
* [`tr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/tr.json) (🇹🇷 Turkish)
|
||||
* [`nl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/nl.json) (🇳🇱 Dutch)
|
||||
* [`ja.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ja.json) (🇯🇵 Japanese)
|
||||
* [`ko.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ko.json) (🇰🇷 Korean)
|
||||
* [`pt.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt.json) (🇵🇹 European Portuguese)
|
||||
* **Translation Engine:** [`extension/i18n.js`](file:///Users/koala/Documents/KoalaPlay/extension/i18n.js)
|
||||
* **Validation Script:** [`scripts/test-locales.js`](file:///Users/koala/Documents/KoalaPlay/scripts/test-locales.js)
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
// KoalaSync Landing Page Logic
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const safeGetLocalStorage = (key) => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeSetLocalStorage = (key, val) => {
|
||||
try {
|
||||
localStorage.setItem(key, val);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll Reveal Logic (IntersectionObserver for performance)
|
||||
const revealElements = document.querySelectorAll('[data-reveal]');
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |