Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d76e9195c4 | |||
| 6652a06840 | |||
| b1a89cff41 | |||
| e4a77a3ef4 | |||
| 5adcce2074 | |||
| a1398ed0e4 | |||
| ed80856803 | |||
| 102031e0d2 | |||
| 503c7d6dc4 | |||
| 77793c8c6e | |||
| 4fbf309e5a | |||
| aa61a24351 | |||
| 2ee5c83ee6 | |||
| ca1cfdb382 | |||
| ef7b1f2e5f | |||
| 10fdaa23fc | |||
| 6ba5e1b10b | |||
| 6e234fb8fd | |||
| 9f553b4f8f | |||
| 131afadc1d | |||
| c62da66b06 | |||
| 34f0c2b265 | |||
| af59b4c64c | |||
| af8184420c | |||
| 45e1e3defe | |||
| e67b0c564d | |||
| f54a38b656 | |||
| 5216fde641 | |||
| 39fe2cbd11 | |||
| 03d5dbda66 | |||
| 0c0cbbc090 | |||
| 0f4ad39fb4 | |||
| cfbb070c57 | |||
| 0caf19cf18 | |||
| ff31f56911 | |||
| 54d03df0af | |||
| 4f1016fa87 |
@@ -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/
|
||||
|
||||
@@ -12,7 +12,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|
||||
|------|-------------|
|
||||
| **Bug Reports** | Found a bug? Open an issue with repro steps (see template below). |
|
||||
| **Code** | Fix bugs, add features, or improve the extension / server / website. |
|
||||
| **Translations** | Help localize the extension and website into more languages. See [TRANSLATION.md](website/TRANSLATION.md). |
|
||||
| **Translations** | Help localize the extension and website into more languages. See [TRANSLATION.md](docs/TRANSLATION.md). |
|
||||
| **Documentation** | Improve docs, fix typos, or add missing examples. |
|
||||
| **Security** | Found a vulnerability? See [SECURITY.md](SECURITY.md) — do NOT open a public issue. |
|
||||
|
||||
@@ -100,15 +100,23 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
## Open Source Workflow & Pull Requests
|
||||
|
||||
1. **Branch** from `main` for your feature or fix.
|
||||
2. **Test locally** on both Chrome and Firefox.
|
||||
3. **Website changes**: Run `node website/build.js` and verify the compiled output in `www/`.
|
||||
4. **Lint**: Ensure `npm run lint` passes with zero errors and zero warnings.
|
||||
5. **Syntax**: Run `node -c` on every modified `.js` file.
|
||||
6. **Protocol changes**: Update relevant documentation in `docs/`.
|
||||
7. **Submit your PR** with a clear description and linked issue (if applicable).
|
||||
If you are new to open-source contributions, follow these steps to propose your changes:
|
||||
|
||||
1. **Fork the Repository**: Click the "Fork" button at the top right of this repository to create your own copy of KoalaSync.
|
||||
2. **Clone your Fork**: `git clone https://github.com/YOUR-USERNAME/KoalaSync.git`
|
||||
3. **Create a Branch**: `git checkout -b my-new-feature` (e.g. `feature/dark-mode` or `fix/translation-de`)
|
||||
4. **Make your Changes**: Edit the files, then verify them locally.
|
||||
- *Extension/Server changes*: Test on Chrome/Firefox and check `npm run lint`.
|
||||
- *Website/Translation changes*: Run `node website/build.js` and check the output in `www/`.
|
||||
5. **Commit and Push**: `git commit -m "Add my feature"` and `git push origin my-new-feature`
|
||||
6. **Open a Pull Request (PR)**: Go to the original KoalaSync repository on GitHub and click "New Pull Request".
|
||||
|
||||
### PR Code Requirements
|
||||
- **Lint**: Ensure `npm run lint` passes with zero errors and warnings.
|
||||
- **Syntax**: Run `node -c` on every modified `.js` file.
|
||||
- **Protocol changes**: Update relevant documentation in `docs/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -140,14 +148,18 @@ If you cannot access the Status tab, include as much of the following manually:
|
||||
|
||||
---
|
||||
|
||||
## Translation Contributions
|
||||
## Translation Contributions (Translators Welcome!)
|
||||
|
||||
KoalaSync supports 6 languages: English, German, French, Spanish, Portuguese (Brazilian), and Russian.
|
||||
We welcome native speakers to help translate KoalaSync! You **do not** need deep programming knowledge to contribute translations.
|
||||
|
||||
To add or improve translations:
|
||||
1. Edit the locale files in `website/locales/` (for the website).
|
||||
2. For extension translations, see [TRANSLATION.md](website/TRANSLATION.md).
|
||||
3. Run `node website/build.js` to regenerate the static site.
|
||||
KoalaSync supports multiple languages. To add or improve translations:
|
||||
1. Read the **[Translation Guide](docs/TRANSLATION.md)** first. It explains how our localization system works.
|
||||
2. Edit the `.json` files in `website/locales/` (for the website) and `extension/locales/` (for the extension).
|
||||
3. Test your translations locally by running:
|
||||
- `node scripts/test-locales.js` (for extension)
|
||||
- `node scripts/test-website-locales.mjs` (for website)
|
||||
- `node website/build.js` (to build the site)
|
||||
4. Follow the **Open Source Workflow** above (Fork -> Branch -> Edit -> PR) to submit your translations.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<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.1 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.3.0 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
@@ -54,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](docs/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 🛠️ For Developers & Self-Hosters
|
||||
@@ -93,15 +103,21 @@ To connect your extension to a self-hosted server, open the popup → **Room** t
|
||||
|
||||
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.3.0+)
|
||||
|
||||
### 🌐 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 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.
|
||||
**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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -109,12 +125,13 @@ Both the official KoalaSync website and the **v2.0 Browser Extension** feature f
|
||||
|
||||
- **[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.
|
||||
- **[TRANSLATION.md](docs/TRANSLATION.md)**: Translation and localization guide for contributors.
|
||||
- **[PRIVACY.md](docs/PRIVACY.md)**: Data Handling and Privacy Policy.
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: How to help make KoalaSync better.
|
||||
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
|
||||
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
|
||||
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
|
||||
- **[ROADMAP.md](docs/ROADMAP.md)**: Planned features, backlog, and rejected ideas.
|
||||
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
|
||||
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
|
||||
|
||||
|
||||
@@ -1,37 +1,57 @@
|
||||
KoalaSync: The Privacy-First Watch Party Extension
|
||||
KoalaSync: Private Watch Parties for Emby, Jellyfin, Plex, Netflix & YouTube
|
||||
|
||||
Private watch parties for Emby, Jellyfin, Plex, Netflix, YouTube & more. No account needed. Real-time sync. Supports 13 languages. 100% free.
|
||||
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.
|
||||
|
||||
Whether you're binge-watching Netflix, streaming from your own self-hosted Emby, Jellyfin, or Plex servers, or watching on virtually any other website with a video player — KoalaSync keeps everyone perfectly in sync.
|
||||
Lightweight, open-source, and built for absolute data sovereignty with a clean, minimalist UI.
|
||||
|
||||
✨ CORE FEATURES
|
||||
✨ CORE FEATURES
|
||||
No account required. No tracking. Just create a room, invite your friends, and start watching together.
|
||||
|
||||
• No Account Needed: Just create a room and share the link. No emails, no passwords, no hassle. A random nickname is assigned automatically, or pick your own.
|
||||
• Universal HTML5 Sync: Flawlessly synchronizes play, pause, and seeking on Netflix, Jellyfin, Emby, YouTube, and almost any website hosting a standard HTML5 <video> tag.
|
||||
• Smart Binge-Watching (Auto-Sync): When a new episode loads, KoalaSync automatically pauses playback in a smart lobby until everyone's video is ready. No spoilers, no one left behind.
|
||||
• Fully Localized: Enjoy a native experience with a user interface fully translated into 13 different languages.
|
||||
• One-Click Invitations: Generate a smart invite link. Once clicked, the extension instantly configures room credentials and server settings for your guests.
|
||||
• Zero-Latency Protocol: Powered by a custom WebSocket architecture designed for real-time coordination without buffering loops.
|
||||
• 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.
|
||||
|
||||
🛡️ UNCOMPROMISING PRIVACY & SECURITY
|
||||
|
||||
No data collection. No compromises.
|
||||
• 100% Anonymous: Zero tracking, zero analytics, zero telemetry.
|
||||
• Ready Out-Of-The-Box: No technical knowledge needed. Install and start watching immediately using the official public server.
|
||||
• RAM-Only Public Server: The official relay server operates entirely in volatile RAM. No databases, no logs, no watch history — nothing persists. It also features built-in rate-limiting and brute-force protection to prevent room-id spamming.
|
||||
• Self-Hostable: Take total control by deploying a private relay server in seconds via Docker. Optional, never required.
|
||||
|
||||
🚀 HOW IT WORKS
|
||||
🛡️ PRIVACY & SECURITY
|
||||
KoalaSync is built for private watch parties without unnecessary data collection.
|
||||
|
||||
1. Install KoalaSync and click "Create Room" to spin up a secure lobby.
|
||||
2. Copy the invite link and send it to your friends.
|
||||
3. Open your favorite streaming site and set the video tab as the active target.
|
||||
4. Press play — everyone stays perfectly in sync.
|
||||
• 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.
|
||||
|
||||
💻 OPEN SOURCE
|
||||
|
||||
KoalaSync was built by a solo developer who needed a fast, secure way to watch movies with friends — and builds tools worth using. The code is fully transparent under the MIT license: audit it, fork it, improve it. Found a bug or have a feature idea? Open an issue on GitHub. Contributions and code reviews are always welcome.
|
||||
|
||||
• Website: https://sync.koalastuff.net
|
||||
• GitHub: https://github.com/Shik3i/KoalaSync
|
||||
🚀 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
|
||||
|
||||
@@ -4,6 +4,81 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.3.1] — 2026-06-15
|
||||
|
||||
### Fixed
|
||||
- **Server: Race condition on concurrent peer joins**: Added a per-peerId serialization lock (`peerJoinLocks`) that prevents two connections with the same `peerId` from both passing the deduplication check and simultaneously registering in `peerToSocket`. Previously, rapid reconnects could leave stale mappings that caused cross-room ACK/PING misdelivery.
|
||||
- **Server: Crash-safe error handling in teardown paths**: Wrapped `removePeerFromRoom` calls in the disconnect, leave, and reaper handlers with try/catch to prevent an unhandled exception in any teardown path from crashing the process.
|
||||
- **Server: Smart unhandled rejection handling**: Replaced the immediate `process.exit(1)` on any `unhandledRejection` with a rate-limited approach — the server now exits only after 5 unhandled rejections within 60 seconds, surviving transient errors while still failing fast on cascading crashes.
|
||||
- **Server: Reduced GC pressure in admin health metrics**: Replaced the three-pass `Array.from()` / `map()` / `reduce()` / `filter()` pattern in `buildHealthPayload()` with a single `for-of` loop, eliminating temporary array allocations proportional to the number of active rooms.
|
||||
- **Server: Test isolation for rate-limit denial counters**: `stopServerForTests()` now resets the `rateLimitDenied` counters between test runs.
|
||||
|
||||
---
|
||||
|
||||
## [v2.3.0] — 2026-06-14
|
||||
|
||||
### Added
|
||||
- **Extension: New Interactive Onboarding Tour**: A fully redesigned, interactive step-by-step onboarding experience.
|
||||
- **Extension: Auto-Switch to Sync Tab**: The UI now intelligently switches to the Sync tab when you join a room to guide video selection.
|
||||
- **Extension: Uninstall URL Integration**: Prepared an uninstall URL setup that works natively across Chrome and Firefox, cleanly attaching browser context for analytics.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Infinite Seek Loop Prevention**: Replaced the fragile time-based seek suppression with an exact target-time verification mechanism, entirely eliminating infinite seek loops on slow buffers.
|
||||
- **Extension: Zombie Connections Resolved**: Implemented a forced disconnect upon ping timeouts, ensuring the extension reliably auto-reconnects when the WebSocket hangs in a half-open state.
|
||||
- **Extension: Room Switching Architecture**: Joining a new room while already connected now explicitly severs the old connection first, preventing state cross-contamination.
|
||||
- **Extension: Join/Leave Race Conditions**: Added UI locks to prevent users from accidentally sending conflicting connection commands via rapid double-clicking.
|
||||
- **Extension: Same-Room Invite Bypass**: Clicking an invite link for the room you are currently in no longer triggers a redundant reconnect, instead instantly confirming the join.
|
||||
- **Extension: Audio settings now propagate immediately to video tabs**: Changes made in the audio options page are now instantly applied to the active video tab. Previously, settings saved to `chrome.storage.local` were not picked up by the background listener, which only watched `chrome.storage.sync`.
|
||||
- **Extension: Audio compressor now logs enable/disable state and resume failures**: The compressor reports when it is activated or bypassed, and warns if the `AudioContext` cannot be resumed (e.g. browser autoplay policy requires a user gesture on the page first).
|
||||
- **Extension: Video heartbeat no longer sent when alone in a room**: The full media metadata `PEER_STATUS` is now only emitted when other peers are present. The session keepalive (background heartbeat) continues to run unaffected, preventing the server reaper from disconnecting idle peers.
|
||||
- **Server: Increased `failedAuthAttempts` eviction threshold from 50k to 200k**: Reduces frequency of expensive batch evictions under high auth-failure volumes, smoothing heap usage.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.4] — 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Extension: Error notifications now respect `browserNotifications` setting**: Server error events (e.g. "Server is restarting") no longer trigger a browser notification when the user has disabled notifications in the extension settings.
|
||||
- **Server: Misleading reconnect message corrected**: The graceful shutdown message no longer tells users to manually reconnect — the extension handles this automatically.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.3] — 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Artifact Attestations (Supply Chain Security)**: All release artifacts (Docker images, extension ZIPs) are now published with signed [SLSA provenance attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) via `actions/attest@v4`. Anyone can verify that an artifact was built from this repository using `gh attestation verify`.
|
||||
- **Admin health: `rateLimits.denied` counters**: New rolling counters track actual rate-limit denials (429 responses), separate from `rateLimits.trackedClients` which reports unique IPs in the tracking window.
|
||||
- **Docker HEALTHCHECK**: Container health is now checked every 30s via `GET /health`.
|
||||
- **`npm start` script**: Server can now be started with `npm start`.
|
||||
|
||||
### Fixed
|
||||
- **Server: `activeLobby` no longer silently overwritten**: If a second peer sends `EPISODE_LOBBY` while a lobby is already active, the request is now ignored instead of destroying the first peer's lobby.
|
||||
- **CORS log sanitization**: Rejected origin headers are sanitized (`\r\n` stripped) to prevent log injection.
|
||||
- **Extension: pagehide resource leak**: `keepAlivePort`, `lobbyPollTimer`, heartbeats, and `MutationObserver` are now properly cleaned up when a tab is hidden or enters bfcache.
|
||||
- **Extension: unhandled storage rejections**: `chrome.storage.session.set()` calls in the disconnect handler now have `.catch(() => {})`.
|
||||
- **`'Pixel'` duplicate in name generator**: Second occurrence replaced with `'Nitro'` for better name diversity.
|
||||
- **`'opposum'` typo**: Corrected to `'opossum'` in the emoji map and added `'Opossum'` to `USERNAME_NOUNS`.
|
||||
- **Test reliability**: `test-server-routes.mjs` now sets `ADMIN_METRICS_TOKEN` before importing the server module, fixing standalone test execution.
|
||||
- **`MAX_PEERS_PER_ROOM` konsistent**: `.env` auf 25 gesetzt (wie `.env.example`).
|
||||
- **pt-BR.json duplicate removed**: Duplicate `FOOTER_DISCLAIMER` key removed from website locale.
|
||||
|
||||
### Changed
|
||||
- **Admin health: `rateLimitEntries` renamed to `rateLimits.trackedClients`**: The field now accurately describes that it tracks unique clients in the rate-limit window, not denial counts. Update your json_exporter/Grafana config accordingly.
|
||||
- **README restructured**: Sections reordered by progressive technical depth. New "Supply Chain Security" subsection under "For Developers & Self-Hosters" with verification commands.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.2] — 2026-06-09
|
||||
|
||||
### Added
|
||||
- **Chrome Web Store i18n Support**: Added `default_locale: "en"` to manifest and created `_locales/*/messages.json` for all 13 supported languages. This unlocks the language selection dropdown in the Chrome Web Store dashboard, allowing translated store listings (title, description) per locale. The extension's own UI translations (`locales/*.json` + `i18n.js`) remain unchanged.
|
||||
- **Locale test coverage**: Extended `scripts/test-locales.js` to validate all `_locales/*/messages.json` files (correct format, required keys, no duplicates) and verify `default_locale` is set in the manifest.
|
||||
|
||||
### Fixed
|
||||
- **Copy Logs button alignment**: Removed stray `margin-top: 8px` inherited from `.secondary` class that pushed the button 8px down in the connection status row.
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.1] — 2026-06-09
|
||||
|
||||
### Added
|
||||
@@ -15,7 +90,7 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
## [v2.2.0] — 2026-06-08
|
||||
|
||||
### Added
|
||||
- **Web Audio API Compressor**: Built-in audio dynamic range compression with four presets (Recommended, Dynamic Range, Vocal Enhancement, Smooth) and fully customizable sliders (threshold, ratio, knee, attack, release, makeup gain). Uses dry/wet crossfade (40ms linear ramp) to avoid clicks. Configured via the new Audio Options page accessible from the Settings tab.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ This directory contains deep-dives into the KoalaSync protocol and architecture.
|
||||
- [HOW_IT_WORKS.md](HOW_IT_WORKS.md): Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md): Communication flows, Dual Heartbeat, and Sync logic.
|
||||
- [SYNC_GUIDE.md](SYNC_GUIDE.md): Protocol constants and sync requirements.
|
||||
- [TRANSLATION.md](TRANSLATION.md): Translation and localization guide for the extension and website.
|
||||
|
||||
@@ -1,14 +1,90 @@
|
||||
# KoalaSync Roadmap
|
||||
|
||||
This document tracks future technical plans and optimizations for the KoalaSync system.
|
||||
> Feature priorities, planned work, backlog, and rejected ideas for KoalaSync.
|
||||
|
||||
---
|
||||
|
||||
## Planned Optimizations & Technical Roadmap
|
||||
## Status Legend
|
||||
|
||||
### 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**:
|
||||
| Badge | Meaning |
|
||||
|---|---|
|
||||
| 🚧 In Progress | Currently being developed |
|
||||
| 📋 Planned | Prioritized for an upcoming phase |
|
||||
| 💡 Backlog | Under evaluation, not yet prioritized |
|
||||
| ❌ Rejected | Declined (with rationale) |
|
||||
| ✅ Completed | Shipped |
|
||||
|
||||
---
|
||||
|
||||
## 🚧 In Progress
|
||||
|
||||
*Currently being worked on.*
|
||||
|
||||
| Feature | Priority | Area |
|
||||
|---|---|---|
|
||||
| *(none yet)* | | |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Planned
|
||||
|
||||
*Prioritized for upcoming phases.*
|
||||
|
||||
### 1. Split large JavaScript files into smaller modules
|
||||
|
||||
- **Priority:** P1
|
||||
- **Category:** Maintainability / AI Context Optimization
|
||||
- **Background:** Core files like `background.js` and `popup.js` have grown large and exceed 800 lines. This makes manual debugging harder and wastes context window space for AI models.
|
||||
- **Planned solution:**
|
||||
- Structurally split logic into separate focused modules (e.g., UI Renderer, Message Router, Storage Manager, Socket Client).
|
||||
- Use ES modules for clean separation and better reusability.
|
||||
|
||||
### 2. Invite link with target URL for auto-redirect
|
||||
|
||||
- **Priority:** P2
|
||||
- **Category:** UX / Ease of Sharing
|
||||
- **Background:** The invite link currently only contains the room ID. The invited person has to manually open the page. Ideally, the link would include the shared tab's URL so the invitee gets redirected to the right page and the tab is auto-selected (auto-matching via tab title already exists).
|
||||
- **Known challenges:**
|
||||
- Many streaming sites (e.g., Emby, Jellyfin) don't have unique URLs per content — once inside the player, the URL stays the same.
|
||||
- Dozens of such edge cases exist; a generic solution is difficult.
|
||||
- Would likely need site-specific extractor logic (similar to the existing sync service adapters).
|
||||
- **Possible approaches:**
|
||||
- Fallback: if no unique URL can be determined, only pass the tab title.
|
||||
- Site-specific URL extraction for known services.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Backlog
|
||||
|
||||
*Ideas and feature requests under evaluation.*
|
||||
|
||||
### In-room chat overlay (like TeleParty)
|
||||
|
||||
- **Priority:** P3
|
||||
- **Category:** Social / Communication
|
||||
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
|
||||
- **Why backlog (still uncertain):**
|
||||
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
|
||||
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
|
||||
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
|
||||
- **Status:** Under evaluation, may come later.
|
||||
|
||||
---
|
||||
|
||||
## ❌ Rejected
|
||||
|
||||
*Declined features with rationale — keeps decisions documented so they don't get re-debated.*
|
||||
|
||||
| Feature | Reason |
|
||||
|---|---|
|
||||
| *(none yet)* | |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
*Shipped milestones.*
|
||||
|
||||
| Feature | Shipped |
|
||||
|---|---|
|
||||
| *(none yet)* | |
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# KoalaSync Translation & Localization Guide
|
||||
|
||||
Welcome to the **KoalaSync** translation guide! We rely on the open-source community to make KoalaSync accessible to users worldwide.
|
||||
|
||||
KoalaSync is split into two independent translation areas. You can translate either one, or both:
|
||||
1. **The Browser Extension** (`extension/locales/`): The core product that users interact with daily.
|
||||
2. **The Website** (`website/locales/`): The landing page and invitation bridge.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Supported Languages Dashboard
|
||||
|
||||
We divide supported languages into two tiers: **Core Languages** (fully hand-crafted and audited by native speakers) and **Extended Languages** (auto-generated using translation models to expand initial coverage).
|
||||
|
||||
> [!TIP]
|
||||
> **Help Us Improve!**
|
||||
> We welcome community contributions to audit "Auto-Generated" translations and elevate them to "Verified" status.
|
||||
|
||||
| Language Code | Language Name | Verification Status | Rationale / Context |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `en` | 🇬🇧 **English** | `100% Manually Verified` | Global default language (Verified by developer) |
|
||||
| `de` | 🇩🇪 **German** | `100% Manually Verified` | Developer's native language |
|
||||
| `fr` | 🇫🇷 **French** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `es` | 🇪🇸 **Spanish** | `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**
|
||||
> Any newly contributed languages must be marked as `"Auto-Generated"` in this table until fully reviewed and signed off by a native speaker in a pull request.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ How to Translate KoalaSync
|
||||
|
||||
Here is the exact step-by-step process for contributing translations.
|
||||
|
||||
### Step 1: Fork and Clone the Repository
|
||||
If you are an external contributor, start with the standard Open Source workflow:
|
||||
1. Click the "Fork" button on GitHub to create your own copy of the repository.
|
||||
2. Clone your fork locally: `git clone https://github.com/YOUR-USERNAME/KoalaSync.git`
|
||||
3. Create a branch: `git checkout -b translation/my-language`
|
||||
|
||||
### Step 2: Translate the Extension
|
||||
The browser extension handles real-time syncing, settings, and popups.
|
||||
1. Navigate to [`extension/locales/`](file:///Users/koala/Documents/Workspaces/KoalaSync/extension/locales/).
|
||||
2. Edit an existing `[lang].json` or copy `en.json` to create a new one (e.g., `it.json`).
|
||||
3. Translate all the string values. **Do not change the JSON keys.**
|
||||
|
||||
### Step 3: Translate the Website
|
||||
The website hosts the landing page and invitation bridge.
|
||||
1. Navigate to [`website/locales/`](file:///Users/koala/Documents/Workspaces/KoalaSync/website/locales/).
|
||||
2. Edit an existing `[lang].json` or copy `en.json` to create a new one.
|
||||
3. Translate all the string values. **Do not change the JSON keys.**
|
||||
4. If creating a **brand new language**, configure the metadata at the top of your JSON file:
|
||||
```json
|
||||
{
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"CANONICAL_PATH": "it/",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN"
|
||||
}
|
||||
```
|
||||
5. If creating a **brand new language**, register it in `website/build.js` by adding it to the `languages` array.
|
||||
|
||||
### Step 4: Verify Locally
|
||||
Ensure your JSON files are valid and all keys match the English baseline. Open your terminal in the KoalaSync root folder and run:
|
||||
```bash
|
||||
# Tests the extension locales for missing keys or syntax errors
|
||||
node scripts/test-locales.js
|
||||
|
||||
# Tests the website locales for missing keys or syntax errors
|
||||
node scripts/test-website-locales.mjs
|
||||
|
||||
# Builds the website with your new translations
|
||||
node website/build.js
|
||||
```
|
||||
*Note: If you receive any errors about missing keys or `TODO` placeholders, please fix them before submitting.*
|
||||
|
||||
### Step 5: Commit and Pull Request
|
||||
1. Open this `TRANSLATION.md` file and add/update your language in the **Supported Languages Dashboard** above. Mark it as `100% Manually Verified` if you are a native speaker.
|
||||
2. Commit your changes: `git commit -m "Update Italian translations"`
|
||||
3. Push to your fork: `git push origin translation/my-language`
|
||||
4. Open a **Pull Request** on the main KoalaSync repository on GitHub.
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ Strict Legal Exclusion Rule
|
||||
|
||||
Our legal pages have strict constraints to protect user privacy and avoid regulatory liabilities.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **DO NOT TRANSLATE LEGAL DOCUMENTS**
|
||||
> The legal notice ([impressum.html](file:///Users/koala/Documents/Workspaces/KoalaSync/website/impressum.html)) and privacy policy ([datenschutz.html](file:///Users/koala/Documents/Workspaces/KoalaSync/website/datenschutz.html)) **MUST remain exclusively in English and German**.
|
||||
>
|
||||
> * **Rationale:** Legal compliance under the European Union General Data Protection Regulation (GDPR) and the German Digital Services Act (DDG). Offering automated translations of legally binding notices introduces compliance risks due to potential mistranslations of liability limits.
|
||||
> * **Technical Fallback:** Our system automatically falls back to **English** for legal pages if a user visits them in an unsupported language, so you do not need to worry about this.
|
||||
@@ -8,6 +8,7 @@ export default [
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
chrome: "readonly",
|
||||
browser: "readonly",
|
||||
window: "readonly",
|
||||
document: "readonly",
|
||||
navigator: "readonly",
|
||||
|
||||
@@ -54,26 +54,51 @@ modules:
|
||||
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: '{.rateLimitEntries.connections}'
|
||||
help: "Number of entries tracked in the connection rate limiter"
|
||||
path: '{.rateLimits.trackedClients.connections}'
|
||||
help: "Unique clients tracked in the connection rate limiter window"
|
||||
|
||||
- name: koalasync_rate_limit_events
|
||||
path: '{.rateLimitEntries.events}'
|
||||
help: "Number of entries in the event rate limiter"
|
||||
path: '{.rateLimits.trackedClients.events}'
|
||||
help: "Unique sockets tracked in the event rate limiter window"
|
||||
|
||||
- name: koalasync_rate_limit_health
|
||||
path: '{.rateLimitEntries.health}'
|
||||
help: "Number of entries in the health rate limiter"
|
||||
path: '{.rateLimits.trackedClients.health}'
|
||||
help: "Unique IPs tracked in the health endpoint rate limiter window"
|
||||
|
||||
- name: koalasync_rate_limit_admin_metrics_auth
|
||||
path: '{.rateLimitEntries.adminMetricsAuth}'
|
||||
help: "Number of entries in the admin metrics auth rate limiter"
|
||||
path: '{.rateLimits.trackedClients.adminMetricsAuth}'
|
||||
help: "Unique IPs tracked in the admin metrics auth rate limiter window"
|
||||
|
||||
- name: koalasync_rate_limit_auth_failures
|
||||
path: '{.rateLimitEntries.authFailures}'
|
||||
help: "Number of entries in the authentication failures cache"
|
||||
path: '{.rateLimits.trackedClients.authFailures}'
|
||||
help: "Unique IPs tracked in the authentication failures cache"
|
||||
|
||||
- name: koalasync_rate_limit_room_list
|
||||
path: '{.rateLimitEntries.roomList}'
|
||||
help: "Number of entries in the room list cooldown cache"
|
||||
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."
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,33 @@ import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, E
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install' || details.reason === 'update') {
|
||||
// --- UNINSTALL_URL_INJECT_START ---
|
||||
const UNINSTALL_URL = ""; // Populated during build
|
||||
const BROWSER_TYPE = "unknown";
|
||||
// --- UNINSTALL_URL_INJECT_END ---
|
||||
|
||||
if (UNINSTALL_URL && UNINSTALL_URL.trim() !== '') {
|
||||
try {
|
||||
const url = new URL(UNINSTALL_URL);
|
||||
url.searchParams.set("browser", BROWSER_TYPE);
|
||||
|
||||
const runtimeAPI = typeof browser !== 'undefined' ? browser.runtime : chrome.runtime;
|
||||
if (runtimeAPI && runtimeAPI.setUninstallURL) {
|
||||
const result = runtimeAPI.setUninstallURL(url.href);
|
||||
// browser.runtime.setUninstallURL returns a Promise, handle rejection silently
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch(err => console.warn('Failed to set uninstall URL:', err));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Invalid uninstall URL provided:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- State Management ---
|
||||
let socket = null;
|
||||
@@ -544,12 +571,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');
|
||||
@@ -708,7 +735,9 @@ function sendPing() {
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
pingTimeout = setTimeout(() => {
|
||||
if (pendingPingT === t) {
|
||||
addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn');
|
||||
pendingPingT = null;
|
||||
forceDisconnect();
|
||||
}
|
||||
pingTimeout = null;
|
||||
}, 5000);
|
||||
@@ -809,7 +838,8 @@ function handleServerEvent(event, data) {
|
||||
isConnecting = false;
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog(`Server Error: ${data.message}`, 'error');
|
||||
chrome.storage.local.get(['locale'], async (settings) => {
|
||||
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
const lang = settings.locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
chrome.notifications.create(`error_${Date.now()}`, {
|
||||
@@ -1332,9 +1362,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
function leaveOldRoomIfSwitching(newRoomId) {
|
||||
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
}
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
@@ -1388,15 +1416,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
await ensureState();
|
||||
|
||||
if (message.type === 'CONNECT') {
|
||||
const settings = await getSettings();
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
if (typeof sendResponse === 'function') sendResponse({ status: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.roomId) {
|
||||
leaveOldRoomIfSwitching(settings.roomId);
|
||||
}
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
@@ -1492,14 +1532,25 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
useCustomServer: !!useCustomServer,
|
||||
serverUrl: serverUrl || ''
|
||||
}, async () => {
|
||||
const settings = await getSettings();
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
broadcastConnectionStatus('connecting');
|
||||
leaveOldRoomIfSwitching(roomId);
|
||||
const settings = await getSettings();
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
@@ -1663,7 +1714,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
markRoomUseful();
|
||||
getSettings().then(settings => {
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||
emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
|
||||
@@ -1820,7 +1872,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'sync' || !changes.audioSettings) return;
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
await ensureState();
|
||||
if (!currentTabId) return;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
const MIN_SEEK_DELTA = 2.0;
|
||||
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
|
||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||
let expectedSeekTime = null; // strictly track programmatic seeks
|
||||
|
||||
// --- Episode Auto-Sync State ---
|
||||
let lastKnownMediaTitle = null;
|
||||
@@ -184,7 +185,7 @@
|
||||
}
|
||||
}
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume().catch(() => {});
|
||||
audioCtx.resume().catch(() => { reportLog('AudioContext resume failed - browser may need page interaction first', 'warn'); });
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
@@ -242,6 +243,7 @@
|
||||
rampGain(chain.dryGain, 1, t);
|
||||
rampGain(chain.compGain, 0, t);
|
||||
chain.active = false;
|
||||
reportLog('Audio compressor disabled', 'info');
|
||||
}
|
||||
|
||||
function bypassCurrentAudioProcessing() {
|
||||
@@ -274,6 +276,7 @@
|
||||
rampGain(chain.dryGain, 0, t);
|
||||
rampGain(chain.compGain, 1, t);
|
||||
chain.active = true;
|
||||
reportLog('Audio compressor enabled', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +439,7 @@
|
||||
ytButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -452,7 +455,7 @@
|
||||
twitchButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -470,7 +473,7 @@
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = data.targetTime;
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -573,7 +576,7 @@
|
||||
return;
|
||||
}
|
||||
_setSuppress('paused');
|
||||
_setSuppress('seek');
|
||||
expectedSeekTime = payload.targetTime;
|
||||
video.pause();
|
||||
try {
|
||||
video.currentTime = payload.targetTime;
|
||||
@@ -837,6 +840,11 @@
|
||||
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
|
||||
@@ -858,11 +866,17 @@
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
|
||||
if (_suppressTimers['seek']) {
|
||||
_clearSuppress('seek');
|
||||
lastReportedSeekTime = current;
|
||||
return;
|
||||
// Step 1: Check expectedSeekTime (programmatic seek from remote peer)
|
||||
if (expectedSeekTime !== null) {
|
||||
if (Math.abs(current - expectedSeekTime) < 1.0) {
|
||||
// Video arrived at expected time. Safely clear and ignore.
|
||||
expectedSeekTime = null;
|
||||
lastReportedSeekTime = current;
|
||||
return;
|
||||
} else {
|
||||
// User manually scrubbed to a DIFFERENT time while we were buffering
|
||||
expectedSeekTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizer",
|
||||
"AUDIO_COMING_SOON": "Demnächst"
|
||||
"AUDIO_COMING_SOON": "Demnächst",
|
||||
"BTN_RESTART_TOUR": "Einführung neu starten",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Startet die Einführung für neue Benutzer erneut",
|
||||
"HINT_SELECT_VIDEO": "Wähle hier dein Video aus!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizer",
|
||||
"AUDIO_COMING_SOON": "Coming soon"
|
||||
"AUDIO_COMING_SOON": "Coming soon",
|
||||
"BTN_RESTART_TOUR": "Restart Tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Restart the onboarding tutorial",
|
||||
"HINT_SELECT_VIDEO": "Select your video here!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Ecualizador",
|
||||
"AUDIO_COMING_SOON": "Próximamente"
|
||||
"AUDIO_COMING_SOON": "Próximamente",
|
||||
"BTN_RESTART_TOUR": "Reiniciar tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar el tutorial de inicio",
|
||||
"HINT_SELECT_VIDEO": "¡Selecciona tu video aquí!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Égaliseur",
|
||||
"AUDIO_COMING_SOON": "Bientôt disponible"
|
||||
"AUDIO_COMING_SOON": "Bientôt disponible",
|
||||
"BTN_RESTART_TOUR": "Redémarrer le tutoriel",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Recommencer le tutoriel d'intégration",
|
||||
"HINT_SELECT_VIDEO": "Sélectionnez votre vidéo ici !"
|
||||
}
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
# KoalaSync Browser Extension v2.0 - i18n Technical Implementation Plan
|
||||
|
||||
Welcome, future Antigravity AI agent! This document is placed directly in the codebase at `/extension/locales/i18n_plan.md` to serve as a comprehensive architectural handbook for the next steps in adding **full internationalization (i18n) support to the browser extension itself** while maintaining 100% video-sync and background communication safety.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Context & Audited Scope
|
||||
|
||||
KoalaSync is a lightweight, premium browser extension (Chrome & Firefox Manifest V3) for synchronized video playback. The landing pages are already compiled dynamically in 6 languages:
|
||||
* English (`en`)
|
||||
* German (`de`)
|
||||
* French (`fr`)
|
||||
* Spanish (`es`)
|
||||
* Portuguese (Brazil) (`pt-BR`)
|
||||
* Russian (`ru`)
|
||||
|
||||
Our goal is to build an identical, premium translation engine for the extension itself.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architectural Choice: Custom JSON Dictionary Engine (Approach B)
|
||||
|
||||
We evaluated two paths:
|
||||
1. **Approach A (Native `chrome.i18n` API):** Uses `_locales/` directory. Too rigid—cannot support real-time dynamic switching inside the extension Settings dropdown without closing/re-opening the popup.
|
||||
2. **Approach B (Custom Unified JSON Engine):** Uses flat JSON files matching our website files (`"KEY": "Value"`). Dynamically merges the target dictionary with baseline English `en.json` at runtime, programmatically providing an **airtight English fallback** and **real-time DOM translations** without popup reload.
|
||||
|
||||
We chose **Approach B** for maximum compatibility, premium real-time toggling, and clean fallback safety.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Resolve, Load, & State Flow
|
||||
|
||||
1. **On launch:** Look for saved language in `chrome.storage.sync.get('locale')`.
|
||||
2. **Fallback Autodetect:** If no saved language, read `navigator.language` or `chrome.i18n.getUILanguage()`.
|
||||
* If the detected locale is supported, set as active.
|
||||
* If not supported, default to English (`en`).
|
||||
3. **Dictionary Resolution:**
|
||||
* Asynchronously load the English baseline dictionary (`/extension/locales/en.json`).
|
||||
* If the target language is different, load target JSON (e.g. `/extension/locales/de.json`) and execute `Object.assign({}, enDict, targetDict)`. This guarantees dynamic translation while cleanly falling back to English for any missing keys.
|
||||
4. **DOM Replacements:** Scan for `data-i18n`, `data-i18n-title`, and `data-i18n-placeholder` attributes, and translate them on the fly.
|
||||
5. **Persistence:** Save dynamic selection modifications from the dropdown into `chrome.storage.sync`. Trigger instant DOM re-translation on change.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Proposed File Structure
|
||||
|
||||
```
|
||||
KoalaPlay/
|
||||
└── extension/
|
||||
├── locales/ # [NEW] Contains flat translation maps
|
||||
│ ├── i18n_plan.md # This roadmap file
|
||||
│ ├── en.json # Flat English baseline keys
|
||||
│ ├── de.json # German keys
|
||||
│ ├── fr.json # French keys
|
||||
│ ├── es.json # Spanish keys
|
||||
│ ├── pt-BR.json # Portuguese (Brasil) keys
|
||||
│ └── ru.json # Russian keys
|
||||
├── i18n.js # [NEW] ESM translation engine module
|
||||
├── popup.html # Modified with data-i18n attributes
|
||||
├── popup.js # Modified to initialize locales and update variables
|
||||
└── background.js # Modified to push localized notification alerts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Draft Code Snippets
|
||||
|
||||
### 1. `i18n.js` (Zero-Dependency Engine Module)
|
||||
```javascript
|
||||
// extension/i18n.js
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
let activeDictionary = {};
|
||||
|
||||
export async function loadLocale(langCode) {
|
||||
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
|
||||
try {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
const enDict = await enResponse.json();
|
||||
|
||||
if (resolvedLang === DEFAULT_LANGUAGE) {
|
||||
activeDictionary = enDict;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetResponse = await fetch(chrome.runtime.getURL(`locales/${resolvedLang}.json`));
|
||||
const targetDict = await targetResponse.json();
|
||||
|
||||
activeDictionary = Object.assign({}, enDict, targetDict);
|
||||
} catch (err) {
|
||||
console.error('[i18n] Failed to load dictionary. Falling back to English:', err);
|
||||
const rescue = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
activeDictionary = await rescue.json();
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessage(key) {
|
||||
return activeDictionary[key] || key;
|
||||
}
|
||||
|
||||
export function translateDOM() {
|
||||
// Translate text nodes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translated = getMessage(key);
|
||||
const img = el.querySelector('img');
|
||||
if (img) {
|
||||
el.innerHTML = '';
|
||||
el.appendChild(img);
|
||||
el.appendChild(document.createTextNode(' ' + translated));
|
||||
} else {
|
||||
el.textContent = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Translate tooltips
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.setAttribute('title', getMessage(key));
|
||||
});
|
||||
|
||||
// Translate placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.setAttribute('placeholder', getMessage(key));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Markup Changes (`popup.html`)
|
||||
* Annotate text elements: `<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP">Settings</button>`
|
||||
* Add Language Dropdown in the Settings Panel:
|
||||
```html
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0;" data-i18n="LABEL_LANGUAGE" title="Choose your preferred extension language">App Language</label>
|
||||
<select id="langSelector" style="width: 150px; padding: 6px 10px; font-size: 13px; cursor: pointer;">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="pt-BR">Português (Brasil)</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Dynamic Script Wiring (`popup.js`)
|
||||
* Import our engine: `import { loadLocale, translateDOM, getMessage } from './i18n.js';`
|
||||
* Initialize during startup:
|
||||
```javascript
|
||||
async function init() {
|
||||
const data = await chrome.storage.sync.get(['locale', ...]);
|
||||
let activeLang = data.locale;
|
||||
if (!activeLang) {
|
||||
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
|
||||
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
|
||||
chrome.storage.sync.set({ locale: activeLang });
|
||||
}
|
||||
await loadLocale(activeLang);
|
||||
translateDOM();
|
||||
|
||||
// Select matching option in selector
|
||||
const langSelector = document.getElementById('langSelector');
|
||||
if (langSelector) langSelector.value = activeLang;
|
||||
|
||||
// rest of standard init...
|
||||
}
|
||||
```
|
||||
* Listen to Settings change event:
|
||||
```javascript
|
||||
const langSelector = document.getElementById('langSelector');
|
||||
if (langSelector) {
|
||||
langSelector.addEventListener('change', async () => {
|
||||
const selectedLang = langSelector.value;
|
||||
await chrome.storage.sync.set({ locale: selectedLang });
|
||||
await loadLocale(selectedLang);
|
||||
translateDOM();
|
||||
// Re-render empty elements and tab list using new dynamic strings
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
populateTabs();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Background Notifications (`background.js`)
|
||||
* Notifications inside `showNotification` should fetch dynamic keys based on `chrome.storage.sync` language settings and build clean alerts dynamically using `chrome.storage.sync.get('locale')`.
|
||||
* Ensure that WebSocket event transmissions and video player scripts in `content.js` remain **completely untouched and unaware** of the localization engine.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Safety Guardrail
|
||||
|
||||
* **WebSocket Engine Integrity:** Under no circumstances should `background.js` Socket.IO handshakes, rate limits, or binary protocol headers be adjusted. i18n is strictly an interface rendering skin layer.
|
||||
* **Player Control Pipeline Integrity:** `content.js` expectedEvent suppression tables, HLS buffer calculations, and Media Session API listeners must remain untouched. Do not bind any i18n logic into the content script's active tracking loop.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verification Tasks
|
||||
|
||||
1. **Autodetection Audit:** Force-load the extension in Chrome/Firefox in a non-English language container and ensure it starts in that language or English gracefully.
|
||||
2. **Integrity Validation Script:** Write a sanity node checker script `/scripts/test-locales.js` to ensure all key structures are uniform across all JSON files.
|
||||
3. **Real-Time Refresh Verification:** Change dynamic drop-down preferences and verify that every element and alert swaps languages cleanly with zero redraw glitches or page crashes.
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizzatore",
|
||||
"AUDIO_COMING_SOON": "Prossimamente"
|
||||
"AUDIO_COMING_SOON": "Prossimamente",
|
||||
"BTN_RESTART_TOUR": "Riavvia tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Riavvia il tutorial iniziale",
|
||||
"HINT_SELECT_VIDEO": "Seleziona il tuo video qui!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "イコライザー",
|
||||
"AUDIO_COMING_SOON": "近日公開"
|
||||
"AUDIO_COMING_SOON": "近日公開",
|
||||
"BTN_RESTART_TOUR": "チュートリアルを再起動",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "オンボーディングチュートリアルを再起動する",
|
||||
"HINT_SELECT_VIDEO": "ここで動画を選択してください!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "이퀄라이저",
|
||||
"AUDIO_COMING_SOON": "출시 예정"
|
||||
"AUDIO_COMING_SOON": "출시 예정",
|
||||
"BTN_RESTART_TOUR": "튜토리얼 다시 시작",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "온보딩 튜토리얼을 다시 시작합니다",
|
||||
"HINT_SELECT_VIDEO": "여기에서 비디오를 선택하세요!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizer",
|
||||
"AUDIO_COMING_SOON": "Binnenkort beschikbaar"
|
||||
"AUDIO_COMING_SOON": "Binnenkort beschikbaar",
|
||||
"BTN_RESTART_TOUR": "Tutorial opnieuw starten",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Start de introductie-tutorial opnieuw",
|
||||
"HINT_SELECT_VIDEO": "Selecteer hier je video!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Korektor",
|
||||
"AUDIO_COMING_SOON": "Wkrótce"
|
||||
"AUDIO_COMING_SOON": "Wkrótce",
|
||||
"BTN_RESTART_TOUR": "Uruchom samouczek ponownie",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Uruchom ponownie samouczek powitalny",
|
||||
"HINT_SELECT_VIDEO": "Wybierz swoje wideo tutaj!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizador",
|
||||
"AUDIO_COMING_SOON": "Em breve"
|
||||
"AUDIO_COMING_SOON": "Em breve",
|
||||
"BTN_RESTART_TOUR": "Reiniciar tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial de introdução",
|
||||
"HINT_SELECT_VIDEO": "Selecione seu vídeo aqui!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Equalizador",
|
||||
"AUDIO_COMING_SOON": "Em breve"
|
||||
"AUDIO_COMING_SOON": "Em breve",
|
||||
"BTN_RESTART_TOUR": "Reiniciar tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial de introdução",
|
||||
"HINT_SELECT_VIDEO": "Selecione o seu vídeo aqui!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Эквалайзер",
|
||||
"AUDIO_COMING_SOON": "Скоро"
|
||||
"AUDIO_COMING_SOON": "Скоро",
|
||||
"BTN_RESTART_TOUR": "Перезапустить обучение",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Запустить приветственное руководство заново",
|
||||
"HINT_SELECT_VIDEO": "Выберите ваше видео здесь!"
|
||||
}
|
||||
|
||||
@@ -205,5 +205,8 @@
|
||||
"AUDIO_PARAM_ATTACK": "Attack",
|
||||
"AUDIO_PARAM_RELEASE": "Release",
|
||||
"AUDIO_EQUALIZER": "Ekolayzer",
|
||||
"AUDIO_COMING_SOON": "Çok yakında"
|
||||
"AUDIO_COMING_SOON": "Çok yakında",
|
||||
"BTN_RESTART_TOUR": "Eğitimi Yeniden Başlat",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Tanıtım eğitimini yeniden başlat",
|
||||
"HINT_SELECT_VIDEO": "Videonuzu buradan seçin!"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -364,6 +364,14 @@
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.3); }
|
||||
}
|
||||
@keyframes spotlightPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 0px var(--accent); }
|
||||
50% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 6px var(--accent); }
|
||||
}
|
||||
@keyframes floatHint {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -457,11 +465,16 @@
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<!-- SYNC ACTIVE: Visible when in a room -->
|
||||
<div id="sync-active">
|
||||
<div class="form-group">
|
||||
<div class="form-group" style="position: relative;">
|
||||
<label title="Choose the browser tab containing the video to sync" data-i18n="LABEL_SELECT_VIDEO" data-i18n-title="LABEL_SELECT_VIDEO_TOOLTIP">Select Video</label>
|
||||
<select id="targetTab">
|
||||
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
|
||||
</select>
|
||||
<!-- Hint Tooltip -->
|
||||
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: white; padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
|
||||
Select your video here!
|
||||
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
@@ -577,7 +590,8 @@
|
||||
|
||||
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
|
||||
<label title="Tools for fixing connection issues" data-i18n="LABEL_TROUBLESHOOTING" data-i18n-title="LABEL_TROUBLESHOOTING_TOOLTIP">Troubleshooting</label>
|
||||
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
|
||||
<button id="restartTourBtn" class="secondary" style="width: 100%; font-size: 11px;" title="Restart the onboarding tutorial" data-i18n="BTN_RESTART_TOUR" data-i18n-title="BTN_RESTART_TOUR_TOOLTIP">Restart Tutorial</button>
|
||||
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px; margin-top: 8px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
|
||||
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;" data-i18n="REGEN_ID_DESC">Use this if you see "Duplicate Identity" errors.</p>
|
||||
<p style="font-size: 9px; text-align: center; margin-top: 6px;"><a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" style="color: var(--accent); text-decoration: none;" data-i18n="REGEN_ID_OTHER_ISSUE">Other issue? Open a GitHub Issue</a></p>
|
||||
</div>
|
||||
@@ -600,7 +614,7 @@
|
||||
<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>
|
||||
@@ -637,16 +651,31 @@
|
||||
<script src="popup.js" type="module"></script>
|
||||
|
||||
<!-- Onboarding Overlay -->
|
||||
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter: blur(2px); z-index:1000; align-items:flex-end; justify-content:center; padding-bottom: 20px;">
|
||||
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; width:90%; text-align:center; box-shadow:0 8px 32px rgba(0,0,0,0.5); ">
|
||||
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">👋</div>
|
||||
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 8px; font-size:16px;">Welcome to KoalaSync!</h2>
|
||||
<p id="onboarding-text" style="color:var(--text-muted); font-size:13px; margin:0 0 16px; line-height:1.4;">Let's get you started.</p>
|
||||
<div style="display:flex; gap:8px; justify-content:center;">
|
||||
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
|
||||
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
|
||||
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:transparent; z-index:1000; pointer-events:auto;">
|
||||
<!-- Spotlight element -->
|
||||
<div id="onboarding-spotlight" style="position:fixed; pointer-events:none; border:2px solid var(--accent); box-shadow:0 0 0 9999px rgba(15, 23, 42, 0.65); border-radius:8px; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display:none; opacity:0; z-index:1001; animation: spotlightPulse 2s infinite ease-in-out;"></div>
|
||||
|
||||
<!-- Onboarding Card -->
|
||||
<div id="onboarding-card" style="position:fixed; background:var(--card); padding:16px 20px; border-radius:12px; width:280px; box-shadow:0 8px 32px rgba(0,0,0,0.5); z-index:1002; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-sizing:border-box; border:1px solid #334155; opacity:0; transform: scale(0.9); display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
|
||||
<span id="onboarding-icon" style="font-size:24px;">👋</span>
|
||||
<span id="onboarding-step-indicator" style="font-size:10px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:0.5px;">Step 1 of 3</span>
|
||||
</div>
|
||||
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
|
||||
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 6px; font-size:14px; font-weight:700; text-align:left;">Welcome to KoalaSync!</h2>
|
||||
<p id="onboarding-text" style="color:var(--text-muted); font-size:12px; margin:0 0 16px; line-height:1.4; text-align:left;">Let's get you started.</p>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div id="onboarding-progress-container" style="height:4px; background:#334155; border-radius:2px; margin-bottom:16px; overflow:hidden;">
|
||||
<div id="onboarding-progress-bar" style="height:100%; width:33%; background:var(--accent); transition:width 0.3s ease;"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; justify-content:space-between; align-items:center;">
|
||||
<button id="onboarding-skip" class="secondary" style="width:auto; padding:6px 12px; margin:0; font-size:11px;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
|
||||
<button id="onboarding-next" class="primary" style="width:auto; padding:6px 16px; margin:0; font-size:11px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip pointer arrow -->
|
||||
<div id="onboarding-arrow" style="position:absolute; width:0; height:0; border:6px solid transparent; transition:all 0.3s ease;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -29,6 +29,7 @@ const elements = {
|
||||
inviteLink: document.getElementById('inviteLink'),
|
||||
filterNoise: document.getElementById('filterNoise'),
|
||||
regenId: document.getElementById('regenId'),
|
||||
restartTourBtn: document.getElementById('restartTourBtn'),
|
||||
lastActionCard: document.getElementById('lastActionCard'),
|
||||
historyList: document.getElementById('historyList'),
|
||||
copyLogs: document.getElementById('copyLogs'),
|
||||
@@ -68,6 +69,7 @@ let lastPeersJson = null;
|
||||
let lastKnownPeers = [];
|
||||
let isDevTabVisible = false;
|
||||
let reconnectSlowMode = false;
|
||||
let isProcessingConnection = false;
|
||||
let joinBtnTimeout = null;
|
||||
let forceSyncResetTimer = null;
|
||||
let popupIntervals = [];
|
||||
@@ -173,7 +175,7 @@ function setRoomRefreshCooldown() {
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
|
||||
// Migrate preferences from sync → local for existing users
|
||||
const oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
|
||||
const toMigrate = {};
|
||||
@@ -259,8 +261,21 @@ async function init() {
|
||||
|
||||
// Render lobby status if active
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
|
||||
if (res.status === 'connected' && !res.targetTabId && localData.roomId) {
|
||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||
if (syncTabBtn) syncTabBtn.click();
|
||||
showSelectVideoHint();
|
||||
} else if (localData.activeTab) {
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
} else {
|
||||
await populateTabs();
|
||||
if (localData.activeTab) {
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1121,6 +1136,8 @@ elements.tabs.forEach(btn => {
|
||||
isDevTabVisible = btn.dataset.tab === 'tab-dev';
|
||||
if (isDevTabVisible) refreshLogs();
|
||||
if (btn.dataset.tab === 'tab-sync') refreshHistory();
|
||||
|
||||
chrome.storage.local.set({ activeTab: btn.dataset.tab });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1163,8 +1180,13 @@ elements.roomId.addEventListener('input', () => {
|
||||
});
|
||||
|
||||
elements.joinBtn.addEventListener('click', async () => {
|
||||
if (isProcessingConnection) return;
|
||||
isProcessingConnection = true;
|
||||
clearConnectionErrorTimer();
|
||||
if (elements.joinBtn.disabled) return;
|
||||
if (elements.joinBtn.disabled) {
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
const roomIdInput = elements.roomId.value.trim();
|
||||
const isCreating = !roomIdInput;
|
||||
|
||||
@@ -1176,6 +1198,7 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
}, 15000);
|
||||
|
||||
@@ -1197,6 +1220,7 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1223,6 +1247,8 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
elements.leaveBtn.addEventListener('click', async () => {
|
||||
if (isProcessingConnection) return;
|
||||
isProcessingConnection = true;
|
||||
clearConnectionErrorTimer();
|
||||
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
||||
await chrome.storage.local.set({ roomId: '', password: '' });
|
||||
@@ -1230,6 +1256,7 @@ elements.leaveBtn.addEventListener('click', async () => {
|
||||
elements.password.value = '';
|
||||
lastKnownPeers = [];
|
||||
updateUI(null, null);
|
||||
isProcessingConnection = false;
|
||||
});
|
||||
|
||||
function generateRoomId() {
|
||||
@@ -1280,6 +1307,9 @@ elements.retryBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.targetTab.addEventListener('change', () => {
|
||||
const hint = document.getElementById('targetTabHint');
|
||||
if (hint) hint.style.display = 'none';
|
||||
|
||||
const val = elements.targetTab.value;
|
||||
const tabId = val ? parseInt(val) : null;
|
||||
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
|
||||
@@ -1289,8 +1319,14 @@ elements.targetTab.addEventListener('change', () => {
|
||||
elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (elements.forceSyncBtn.disabled) return;
|
||||
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
elements.forceSyncBtn.disabled = true;
|
||||
|
||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||
if (chrome.runtime.lastError || !status || !status.targetTabId) return;
|
||||
if (chrome.runtime.lastError || !status || !status.targetTabId) {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = elements.forceSyncMode.value;
|
||||
let targetTime = null;
|
||||
@@ -1298,6 +1334,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (mode === 'jump-to-others') {
|
||||
if (!localPeerId) {
|
||||
showError(getMessage('ERR_IDENTITY_NOT_LOADED'));
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const peers = status.peers || [];
|
||||
@@ -1307,6 +1344,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
|
||||
if (otherTimes.length === 0) {
|
||||
showError(getMessage('ERR_NO_PEERS_TIME'));
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1315,8 +1353,6 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
targetTime = otherTimes.length % 2 !== 0 ? otherTimes[mid] : (otherTimes[mid - 1] + otherTimes[mid]) / 2;
|
||||
}
|
||||
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
elements.forceSyncBtn.disabled = true;
|
||||
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? getMessage('BTN_STATE_SYNCING_GROUP', { time: formatTime(targetTime) }) : getMessage('BTN_STATE_SYNCING');
|
||||
forceSyncDone = false;
|
||||
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
|
||||
@@ -1610,6 +1646,7 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
} else if (msg.type === 'ROOM_LIST') {
|
||||
updateRoomList(msg.rooms);
|
||||
} else if (msg.type === 'JOIN_STATUS') {
|
||||
isProcessingConnection = false;
|
||||
if (joinBtnTimeout) {
|
||||
clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = null;
|
||||
@@ -1621,9 +1658,15 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
// Final confirmation of join from background
|
||||
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||
if (syncTabBtn) syncTabBtn.click();
|
||||
showSelectVideoHint();
|
||||
});
|
||||
} else {
|
||||
// Join failed: reset UI state
|
||||
chrome.storage.local.set({ roomId: '', password: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
updateUI(null, null);
|
||||
}
|
||||
} else if (msg.type === 'LOBBY_UPDATE') {
|
||||
@@ -1711,6 +1754,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 : [];
|
||||
@@ -2048,36 +2092,181 @@ function updateLobbyUI(lobby, peers) {
|
||||
|
||||
// --- Onboarding Tour ---
|
||||
const onboardingSteps = [
|
||||
{ icon: '👋', get title() { return getMessage('ONBOARDING_1_TITLE'); }, get text() { return getMessage('ONBOARDING_1_TEXT'); }, targetTab: 'tab-room' },
|
||||
{ icon: '🏠', get title() { return getMessage('ONBOARDING_2_TITLE'); }, get text() { return getMessage('ONBOARDING_2_TEXT'); }, targetTab: 'tab-room' },
|
||||
{ icon: '🎬', get title() { return getMessage('ONBOARDING_3_TITLE'); }, get text() { return getMessage('ONBOARDING_3_TEXT'); }, targetTab: 'tab-sync' },
|
||||
{ icon: '⚙️', get title() { return getMessage('ONBOARDING_4_TITLE'); }, get text() { return getMessage('ONBOARDING_4_TEXT'); }, targetTab: 'tab-settings' },
|
||||
{ icon: '🎉', get title() { return getMessage('ONBOARDING_5_TITLE'); }, get text() { return getMessage('ONBOARDING_5_TEXT'); }, targetTab: 'tab-room' }
|
||||
{
|
||||
icon: '👋',
|
||||
get title() { return getMessage('ONBOARDING_1_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_1_TEXT'); },
|
||||
targetTab: 'tab-room',
|
||||
targetSelector: 'h1'
|
||||
},
|
||||
{
|
||||
icon: '🏠',
|
||||
get title() { return getMessage('ONBOARDING_2_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_2_TEXT'); },
|
||||
targetTab: 'tab-room',
|
||||
targetSelector: '#createRoomBtn'
|
||||
},
|
||||
{
|
||||
icon: '🎬',
|
||||
get title() { return getMessage('ONBOARDING_3_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_3_TEXT'); },
|
||||
targetTab: 'tab-sync',
|
||||
targetSelector: '#targetTab'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
get title() { return getMessage('ONBOARDING_4_TITLE'); },
|
||||
get text() { return getMessage('ONBOARDING_4_TEXT'); },
|
||||
targetTab: 'tab-settings',
|
||||
targetSelector: '#username'
|
||||
}
|
||||
];
|
||||
|
||||
function showSelectVideoHint() {
|
||||
const hint = document.getElementById('targetTabHint');
|
||||
if (hint && !elements.targetTab.value) {
|
||||
hint.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
let onboardingStep = 0;
|
||||
let onboardingTimeout = null;
|
||||
|
||||
function showOnboarding() {
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
if (!overlay) return;
|
||||
document.body.style.minHeight = '400px';
|
||||
overlay.style.display = 'flex';
|
||||
document.body.style.minHeight = '420px';
|
||||
overlay.style.display = 'block';
|
||||
onboardingStep = 0;
|
||||
renderOnboardingStep();
|
||||
}
|
||||
|
||||
function positionSpotlightAndCard(targetEl) {
|
||||
const spotlight = document.getElementById('onboarding-spotlight');
|
||||
const card = document.getElementById('onboarding-card');
|
||||
const arrow = document.getElementById('onboarding-arrow');
|
||||
if (!spotlight || !card || !arrow) return;
|
||||
|
||||
spotlight.style.display = 'block';
|
||||
card.style.display = 'block';
|
||||
|
||||
// Force a reflow
|
||||
card.getBoundingClientRect();
|
||||
spotlight.getBoundingClientRect();
|
||||
|
||||
const targetRect = targetEl.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
const sLeft = targetRect.left - pad;
|
||||
const sTop = targetRect.top - pad;
|
||||
const sWidth = targetRect.width + (pad * 2);
|
||||
const sHeight = targetRect.height + (pad * 2);
|
||||
|
||||
spotlight.style.left = `${sLeft}px`;
|
||||
spotlight.style.top = `${sTop}px`;
|
||||
spotlight.style.width = `${sWidth}px`;
|
||||
spotlight.style.height = `${sHeight}px`;
|
||||
spotlight.style.opacity = '1';
|
||||
|
||||
const cardWidth = 280;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
let cLeft = targetRect.left + (targetRect.width - cardWidth) / 2;
|
||||
cLeft = Math.max(12, Math.min(320 - cardWidth - 12, cLeft));
|
||||
|
||||
const cardHeight = card.offsetHeight || 150;
|
||||
let cTop = targetRect.bottom + 14;
|
||||
let arrowDir = 'up';
|
||||
|
||||
if (cTop + cardHeight > viewportHeight - 12) {
|
||||
cTop = targetRect.top - cardHeight - 14;
|
||||
arrowDir = 'down';
|
||||
}
|
||||
|
||||
if (cTop < 12) {
|
||||
cTop = targetRect.bottom + 14;
|
||||
arrowDir = 'up';
|
||||
}
|
||||
|
||||
card.style.left = `${cLeft}px`;
|
||||
card.style.top = `${cTop}px`;
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'scale(1)';
|
||||
|
||||
arrow.style.left = '';
|
||||
arrow.style.right = '';
|
||||
arrow.style.top = '';
|
||||
arrow.style.bottom = '';
|
||||
arrow.style.borderColor = 'transparent';
|
||||
|
||||
const targetCenter = targetRect.left + (targetRect.width / 2);
|
||||
const arrowLeft = targetCenter - cLeft - 6;
|
||||
arrow.style.left = `${Math.max(12, Math.min(cardWidth - 24, arrowLeft))}px`;
|
||||
|
||||
if (arrowDir === 'up') {
|
||||
arrow.style.top = '-12px';
|
||||
arrow.style.borderBottomColor = 'var(--card)';
|
||||
} else {
|
||||
arrow.style.bottom = '-12px';
|
||||
arrow.style.borderTopColor = 'var(--card)';
|
||||
}
|
||||
}
|
||||
|
||||
function centerCardFallback() {
|
||||
const spotlight = document.getElementById('onboarding-spotlight');
|
||||
const card = document.getElementById('onboarding-card');
|
||||
const arrow = document.getElementById('onboarding-arrow');
|
||||
if (!card) return;
|
||||
|
||||
if (spotlight) {
|
||||
spotlight.style.display = 'none';
|
||||
spotlight.style.opacity = '0';
|
||||
}
|
||||
if (arrow) {
|
||||
arrow.style.borderColor = 'transparent';
|
||||
}
|
||||
|
||||
card.style.display = 'block';
|
||||
|
||||
const cardWidth = 280;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
const cLeft = (320 - cardWidth) / 2;
|
||||
const cTop = (viewportHeight - (card.offsetHeight || 150)) / 2;
|
||||
|
||||
card.style.left = `${cLeft}px`;
|
||||
card.style.top = `${cTop}px`;
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'scale(1)';
|
||||
}
|
||||
|
||||
function renderOnboardingStep() {
|
||||
if (onboardingTimeout) clearTimeout(onboardingTimeout);
|
||||
|
||||
const step = onboardingSteps[onboardingStep];
|
||||
const icon = document.getElementById('onboarding-icon');
|
||||
const title = document.getElementById('onboarding-title');
|
||||
const text = document.getElementById('onboarding-text');
|
||||
const nextBtn = document.getElementById('onboarding-next');
|
||||
const dots = document.getElementById('onboarding-dots');
|
||||
if (!icon || !title || !text || !nextBtn || !dots) return;
|
||||
const stepIndicator = document.getElementById('onboarding-step-indicator');
|
||||
const progressBar = document.getElementById('onboarding-progress-bar');
|
||||
|
||||
if (!icon || !title || !text || !nextBtn) return;
|
||||
|
||||
icon.textContent = step.icon;
|
||||
title.textContent = step.title;
|
||||
text.textContent = step.text;
|
||||
|
||||
if (stepIndicator) {
|
||||
stepIndicator.textContent = `Step ${onboardingStep + 1} of ${onboardingSteps.length}`;
|
||||
}
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${((onboardingStep + 1) / onboardingSteps.length) * 100}%`;
|
||||
}
|
||||
|
||||
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1
|
||||
? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!')
|
||||
: getMessage('BTN_ONBOARDING_NEXT');
|
||||
|
||||
if (step.targetTab) {
|
||||
const tabBtn = document.querySelector(`.tab-btn[data-tab="${step.targetTab}"]`);
|
||||
if (tabBtn) tabBtn.click();
|
||||
@@ -2088,31 +2277,53 @@ function renderOnboardingStep() {
|
||||
if (syncActive) syncActive.style.display = 'block';
|
||||
if (syncInactive) syncInactive.style.display = 'none';
|
||||
} else {
|
||||
// Restore actual lock state when on other tabs so we don't leave it unlocked
|
||||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||||
if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none';
|
||||
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
dots.replaceChildren();
|
||||
onboardingSteps.forEach((_, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = `width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};`;
|
||||
dots.appendChild(dot);
|
||||
});
|
||||
const spotlight = document.getElementById('onboarding-spotlight');
|
||||
const card = document.getElementById('onboarding-card');
|
||||
if (spotlight) spotlight.style.opacity = '0';
|
||||
if (card) {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.95)';
|
||||
}
|
||||
|
||||
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1 ? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!') : getMessage('BTN_ONBOARDING_NEXT');
|
||||
onboardingTimeout = setTimeout(() => {
|
||||
const targetEl = document.querySelector(step.targetSelector);
|
||||
if (targetEl && targetEl.offsetParent !== null) {
|
||||
positionSpotlightAndCard(targetEl);
|
||||
} else {
|
||||
centerCardFallback();
|
||||
}
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function completeOnboarding() {
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
document.body.style.minHeight = '';
|
||||
chrome.storage.sync.set({ onboardingComplete: true });
|
||||
if (onboardingTimeout) clearTimeout(onboardingTimeout);
|
||||
|
||||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||||
toggleUIState(inRoom);
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
const spotlight = document.getElementById('onboarding-spotlight');
|
||||
const card = document.getElementById('onboarding-card');
|
||||
|
||||
if (card) {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'scale(0.9)';
|
||||
}
|
||||
if (spotlight) {
|
||||
spotlight.style.opacity = '0';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
document.body.style.minHeight = '';
|
||||
chrome.storage.sync.set({ onboardingComplete: true });
|
||||
|
||||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||||
toggleUIState(inRoom);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
document.getElementById('onboarding-next')?.addEventListener('click', () => {
|
||||
@@ -2129,3 +2340,8 @@ document.getElementById('onboarding-skip')?.addEventListener('click', completeOn
|
||||
document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'onboarding-overlay') completeOnboarding();
|
||||
});
|
||||
|
||||
elements.restartTourBtn?.addEventListener('click', () => {
|
||||
onboardingStep = 0;
|
||||
showOnboarding();
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.1.3",
|
||||
"version": "2.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "2.1.3",
|
||||
"version": "2.2.4",
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -28,9 +28,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -45,9 +45,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -62,9 +62,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -79,9 +79,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -96,9 +96,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -113,9 +113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -130,9 +130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -147,9 +147,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -164,9 +164,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -181,9 +181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -198,9 +198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -215,9 +215,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -232,9 +232,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -249,9 +249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -266,9 +266,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -283,9 +283,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -300,9 +300,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -317,9 +317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -334,9 +334,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -351,9 +351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -368,9 +368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -385,9 +385,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -402,9 +402,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -419,9 +419,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -436,9 +436,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -453,9 +453,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1835,9 +1835,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1848,32 +1848,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -38,7 +38,7 @@ const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
|
||||
|
||||
// Helper to copy files, ignoring manifest.json and manifest.base.json
|
||||
// Also injects shared constants into content.js
|
||||
function copyExtensionFiles(targetDir) {
|
||||
function copyExtensionFiles(targetDir, browserName) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Read master constants for injection
|
||||
@@ -99,6 +99,28 @@ function copyExtensionFiles(targetDir) {
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log('✓ Injected shared constants into content.js');
|
||||
} else if (item === 'background.js') {
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
|
||||
// 3. Inject Uninstall URL Constants
|
||||
const uStart = '// --- UNINSTALL_URL_INJECT_START ---';
|
||||
const uEnd = '// --- UNINSTALL_URL_INJECT_END ---';
|
||||
const uPattern = new RegExp(`${uStart}[\\s\\S]+?${uEnd}`);
|
||||
const placeholderUrl = "https://bye.koalastuff.net/c/camp_99ztjRVbK1BNN2RU";
|
||||
|
||||
let uRep = `${uStart}\n // This block is automatically updated by /scripts/build-extension.js\n`;
|
||||
uRep += ` const UNINSTALL_URL = "${placeholderUrl}";\n`;
|
||||
uRep += ` const BROWSER_TYPE = "${browserName}";\n`;
|
||||
uRep += ` ${uEnd}`;
|
||||
|
||||
if (uPattern.test(content)) {
|
||||
content = content.replace(uPattern, uRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Uninstall URL markers not found in background.js');
|
||||
}
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
@@ -127,7 +149,7 @@ async function buildBrowser(browserName, manifestModifier) {
|
||||
const browserDistDir = path.join(distDir, browserName);
|
||||
|
||||
// 1. Copy files
|
||||
copyExtensionFiles(browserDistDir);
|
||||
copyExtensionFiles(browserDistDir, browserName);
|
||||
|
||||
// 2. Modify and write manifest
|
||||
const browserManifest = manifestModifier(JSON.parse(JSON.stringify(baseManifest)));
|
||||
|
||||
@@ -48,7 +48,7 @@ try {
|
||||
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
const enKeys = Object.keys(enDict);
|
||||
|
||||
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json') && file !== 'en.json');
|
||||
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json'));
|
||||
|
||||
console.log(`Auditing i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
|
||||
|
||||
@@ -79,8 +79,21 @@ for (const file of localeFiles) {
|
||||
|
||||
const missingKeys = enKeys.filter(k => !keys.includes(k));
|
||||
const extraKeys = keys.filter(k => !enKeys.includes(k));
|
||||
const emptyKeys = keys.filter(k => typeof dict[k] === 'string' && dict[k].trim() === '');
|
||||
const placeholderKeys = keys.filter(k => {
|
||||
if (typeof dict[k] !== 'string') return false;
|
||||
const val = dict[k];
|
||||
const lower = val.toLowerCase();
|
||||
return (
|
||||
lower.includes('placeholder') ||
|
||||
lower.includes('todo:') ||
|
||||
lower.includes('tbd:') ||
|
||||
/\bTODO\b/.test(val) ||
|
||||
/\bTBD\b/.test(val)
|
||||
);
|
||||
});
|
||||
|
||||
if (missingKeys.length > 0 || extraKeys.length > 0) {
|
||||
if (missingKeys.length > 0 || extraKeys.length > 0 || emptyKeys.length > 0 || placeholderKeys.length > 0) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${file} has inconsistencies:`);
|
||||
if (missingKeys.length > 0) {
|
||||
@@ -89,6 +102,12 @@ for (const file of localeFiles) {
|
||||
if (extraKeys.length > 0) {
|
||||
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
|
||||
}
|
||||
if (emptyKeys.length > 0) {
|
||||
console.error(` Empty translation values (${emptyKeys.length}):`, emptyKeys);
|
||||
}
|
||||
if (placeholderKeys.length > 0) {
|
||||
console.error(` Placeholder/TODO values (${placeholderKeys.length}):`, placeholderKeys);
|
||||
}
|
||||
} else {
|
||||
console.log(`✓ ${file} is fully consistent (matches all keys).`);
|
||||
}
|
||||
@@ -98,11 +117,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) {
|
||||
|
||||
@@ -17,7 +17,7 @@ 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')
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.sort();
|
||||
|
||||
let hasError = false;
|
||||
@@ -51,8 +51,24 @@ for (const file of localeFiles) {
|
||||
|
||||
const missingKeys = enKeys.filter(k => !keys.includes(k));
|
||||
const extraKeys = keys.filter(k => !enKeys.includes(k));
|
||||
const emptyKeys = keys.filter(k => {
|
||||
if (file === 'en.json' && k === 'CANONICAL_PATH') return false;
|
||||
return typeof dict[k] === 'string' && dict[k].trim() === '';
|
||||
});
|
||||
const placeholderKeys = keys.filter(k => {
|
||||
if (typeof dict[k] !== 'string') return false;
|
||||
const val = dict[k];
|
||||
const lower = val.toLowerCase();
|
||||
return (
|
||||
lower.includes('placeholder') ||
|
||||
lower.includes('todo:') ||
|
||||
lower.includes('tbd:') ||
|
||||
/\bTODO\b/.test(val) ||
|
||||
/\bTBD\b/.test(val)
|
||||
);
|
||||
});
|
||||
|
||||
if (missingKeys.length > 0 || extraKeys.length > 0) {
|
||||
if (missingKeys.length > 0 || extraKeys.length > 0 || emptyKeys.length > 0 || placeholderKeys.length > 0) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${file} has inconsistencies:`);
|
||||
if (missingKeys.length > 0) {
|
||||
@@ -61,6 +77,12 @@ for (const file of localeFiles) {
|
||||
if (extraKeys.length > 0) {
|
||||
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
|
||||
}
|
||||
if (emptyKeys.length > 0) {
|
||||
console.error(` Empty translation values (${emptyKeys.length}):`, emptyKeys);
|
||||
}
|
||||
if (placeholderKeys.length > 0) {
|
||||
console.error(` Placeholder/TODO values (${placeholderKeys.length}):`, placeholderKeys);
|
||||
}
|
||||
} else {
|
||||
console.log(`✓ ${file} is fully consistent (matches all keys).`);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
@@ -114,6 +115,7 @@ export const rooms = new Map();
|
||||
const socketToRoom = new Map();
|
||||
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
||||
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
|
||||
const peerJoinLocks = new Map(); // peerId -> Promise (prevents race on same peerId joins)
|
||||
|
||||
function log(type, message, details = '') {
|
||||
const debugLogging = process.env.DEBUG_LOGGING === '1';
|
||||
@@ -147,22 +149,22 @@ function checkAuthRate(ip, roomId) {
|
||||
}
|
||||
|
||||
function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
const now = Date.now();
|
||||
// 1. Clear expired entries (> 15 mins)
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
} else {
|
||||
break; // Since entries are insertion-ordered by time, all subsequent entries are newer
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries (first items in Map order)
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing insertion-order eviction.');
|
||||
// 2. If still over 200k, perform LRU-style eviction on the oldest 10,000 entries (first items in Map order)
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.');
|
||||
for (const [key] of failedAuthAttempts.entries()) {
|
||||
if (failedAuthAttempts.size <= 40000) {
|
||||
if (failedAuthAttempts.size <= 190000) {
|
||||
break;
|
||||
}
|
||||
failedAuthAttempts.delete(key);
|
||||
@@ -192,6 +194,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 +239,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 +250,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 +261,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 +272,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,7 +470,20 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdByMe) {
|
||||
let peerLockPromise = peerJoinLocks.get(peerId);
|
||||
if (peerLockPromise) {
|
||||
await peerLockPromise;
|
||||
room = rooms.get(roomId);
|
||||
if (!room) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Room no longer exists" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
let resolvePeerLock;
|
||||
peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; });
|
||||
peerJoinLocks.set(peerId, peerLockPromise);
|
||||
try {
|
||||
if (!createdByMe) {
|
||||
if (room.passwordHash) {
|
||||
if (!password || hashPassword(password) !== room.passwordHash) {
|
||||
recordAuthFailure(ip, roomId);
|
||||
@@ -509,6 +541,10 @@ io.on('connection', (socket) => {
|
||||
activeLobby: room.activeLobby || null
|
||||
});
|
||||
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
|
||||
} finally {
|
||||
peerJoinLocks.delete(peerId);
|
||||
resolvePeerLock();
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', `Join error for ${socket.id}`, err);
|
||||
if (socket.connected) {
|
||||
@@ -585,7 +621,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 +649,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;
|
||||
}
|
||||
@@ -625,10 +662,14 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
socket.on(EVENTS.LEAVE_ROOM, () => {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
socket.leave(mapping.roomId);
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
|
||||
try {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
socket.leave(mapping.roomId);
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', 'removePeerFromRoom failed in leave', err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -704,10 +745,11 @@ io.on('connection', (socket) => {
|
||||
roomListCooldowns.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
// Socket is already disconnected — no need to call socket.leave().
|
||||
// removePeerFromRoom uses io.to() for notifications, which correctly
|
||||
// excludes this dead socket since it has already left all rooms.
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
|
||||
try {
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
|
||||
} catch (err) {
|
||||
log('ERROR', 'removePeerFromRoom failed in disconnect', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -732,12 +774,14 @@ const roomCleanupInterval = setInterval(() => {
|
||||
}
|
||||
}
|
||||
for (const sid of staleSids) {
|
||||
// Gracefully evict the socket from the Socket.IO room if it is
|
||||
// still technically connected (zombie with no heartbeat).
|
||||
const deadSocket = io.sockets?.sockets?.get(sid);
|
||||
if (deadSocket) deadSocket.leave(roomId);
|
||||
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
|
||||
removePeerFromRoom(sid, roomId, 'reaper');
|
||||
try {
|
||||
removePeerFromRoom(sid, roomId, 'reaper');
|
||||
} catch (err) {
|
||||
log('ERROR', 'removePeerFromRoom failed in reaper', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Prune empty or inactive rooms
|
||||
@@ -778,7 +822,7 @@ export function startServer(port = PORT, host) {
|
||||
function gracefulShutdown(signal) {
|
||||
log('SERVER', `${signal} received — starting graceful shutdown...`);
|
||||
// 1. Notify all connected clients so they can display a meaningful message
|
||||
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Please reconnect in a moment.' });
|
||||
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Reconnecting automatically...' });
|
||||
// 2. Stop accepting new HTTP connections
|
||||
httpServer.close(() => {
|
||||
log('SERVER', 'HTTP server closed. Exiting.');
|
||||
@@ -799,6 +843,7 @@ export async function stopServerForTests() {
|
||||
socketToRoom.clear();
|
||||
peerToSocket.clear();
|
||||
roomCreationLocks.clear();
|
||||
peerJoinLocks.clear();
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
@@ -808,6 +853,7 @@ export async function stopServerForTests() {
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
io.disconnectSockets(true);
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
if (!httpServer.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
httpServer.close((err) => err ? reject(err) : resolve());
|
||||
@@ -827,8 +873,19 @@ if (isMainModule) {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
let unhandledRejectionCount = 0;
|
||||
let unhandledRejectionReset = Date.now();
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
log('ERROR', `Unhandled rejection: ${reason}`, reason?.stack || '');
|
||||
const now = Date.now();
|
||||
if (now - unhandledRejectionReset > 60000) {
|
||||
unhandledRejectionCount = 0;
|
||||
unhandledRejectionReset = now;
|
||||
}
|
||||
unhandledRejectionCount++;
|
||||
if (unhandledRejectionCount >= 5) {
|
||||
log('ERROR', `Too many unhandled rejections (${unhandledRejectionCount}/min) — aborting`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ export function buildHealthPayload({
|
||||
now = Date.now(),
|
||||
uptime = 0,
|
||||
memoryUsage = () => process.memoryUsage(),
|
||||
rateLimitSizes = {}
|
||||
rateLimitSizes = {},
|
||||
rateLimitDenied = {}
|
||||
}) {
|
||||
const payload = {
|
||||
status: 'ok',
|
||||
@@ -60,28 +61,42 @@ export function buildHealthPayload({
|
||||
|
||||
if (!includeMetrics) return payload;
|
||||
|
||||
const roomValues = Array.from(rooms.values());
|
||||
const roomSizes = roomValues.map(room => room.peers?.size || 0);
|
||||
const peers = roomSizes.reduce((sum, size) => sum + size, 0);
|
||||
const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0;
|
||||
const avgPeersPerRoom = roomSizes.length > 0
|
||||
? Math.round((peers / roomSizes.length) * 100) / 100
|
||||
let peers = 0;
|
||||
let maxPeersInRoom = 0;
|
||||
let roomsWithLobby = 0;
|
||||
for (const room of rooms.values()) {
|
||||
const size = room.peers?.size || 0;
|
||||
peers += size;
|
||||
if (size > maxPeersInRoom) maxPeersInRoom = size;
|
||||
if (room.activeLobby) roomsWithLobby++;
|
||||
}
|
||||
const avgPeersPerRoom = rooms.size > 0
|
||||
? Math.round((peers / rooms.size) * 100) / 100
|
||||
: 0;
|
||||
const mem = memoryUsage();
|
||||
|
||||
return {
|
||||
...payload,
|
||||
peers,
|
||||
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
|
||||
roomsWithLobby,
|
||||
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": [],
|
||||
|
||||
@@ -21,7 +21,7 @@ export const USERNAME_ADJECTIVES = [
|
||||
'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden',
|
||||
'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson',
|
||||
'Turbo', 'Zen', 'Pixel', 'Cyber', 'Solar', 'Lunar', 'Astro', 'Hyper',
|
||||
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Pixel', 'Warp', 'Frost',
|
||||
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Nitro', 'Warp', 'Frost',
|
||||
];
|
||||
|
||||
export const USERNAME_NOUNS = [
|
||||
@@ -39,7 +39,7 @@ export const USERNAME_NOUNS = [
|
||||
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
|
||||
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
|
||||
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
|
||||
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
|
||||
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Opossum', 'Squid',
|
||||
'Jellyfish', 'Turtle',
|
||||
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
|
||||
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
|
||||
@@ -102,7 +102,7 @@ export const ANIMAL_EMOJI_MAP = {
|
||||
'mammoth': '🦣',
|
||||
'meerkat': '🦦',
|
||||
'octopus': '🐙',
|
||||
'opposum': '🐭',
|
||||
'opossum': '🐭',
|
||||
'ostrich': '🐦',
|
||||
'panther': '🐆',
|
||||
'pelican': '🦩',
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# KoalaSync Translation & Localization Guide
|
||||
|
||||
Welcome to the **KoalaSync** translation and internationalization framework! This document provides clear, professional instructions for developers and contributors looking to maintain, audit, or add new languages to the official KoalaSync website.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architecture Overview
|
||||
|
||||
The KoalaSync website utilizes a custom, high-performance, zero-dependency static site generator built in Node.js. Instead of using complex client-side translation runtimes or bulky frameworks, localized pages are compiled ahead-of-time (AOT) to maintain lightning-fast page speeds and strict data sovereignty.
|
||||
|
||||
* **Template Source:** [`website/template.html`](file:///Users/koala/Documents/KoalaPlay/website/template.html) (Single Source of Truth)
|
||||
* **Locales Source:** `/website/locales/[lang].json` (Structured JSON translation dictionaries)
|
||||
* **Build Pipeline:** [`website/build.js`](file:///Users/koala/Documents/KoalaPlay/website/build.js) (Pure Node.js script that compiles pages into `/website/www/`)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Supported Languages Dashboard
|
||||
|
||||
We divide supported languages into two tiers: **Core Languages** (fully hand-crafted and audited by native speakers) and **Extended Languages** (auto-generated using translation models to expand initial coverage).
|
||||
|
||||
> [!TIP]
|
||||
> **Help Us Improve!**
|
||||
> We welcome community contributions to audit "Auto-Generated" translations and elevate them to "Verified" status.
|
||||
|
||||
| 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 |
|
||||
| `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**
|
||||
> Any newly contributed languages must be committed as `"Auto-Generated"` until fully reviewed and signed off by a native speaker in a pull request.
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ Strict Legal Exclusion Rule
|
||||
|
||||
Our legal pages have strict constraints to protect user privacy and avoid regulatory liabilities.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **DO NOT TRANSLATE LEGAL DOCUMENTS**
|
||||
> The legal notice ([impressum.html](file:///Users/koala/Documents/KoalaPlay/website/impressum.html)) and privacy policy ([datenschutz.html](file:///Users/koala/Documents/KoalaPlay/website/datenschutz.html)) **MUST remain exclusively in English and German**.
|
||||
>
|
||||
> * **Rationale:** Legal compliance under the European Union General Data Protection Regulation (GDPR) and the German Digital Services Act (DDG). Offering automated translations of legally binding notices introduces compliance risks due to potential mistranslations of liability limits.
|
||||
> * **Technical Fallback:** The dynamic initializer script (`lang-init.js`) is configured to automatically fallback to **English** for legal pages if a user visits them with a French, Spanish, or other unsupported language preference, keeping their dynamic dropdown choice intact for homepage links.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Step-by-Step: Adding a New Language
|
||||
|
||||
Adding a new language (e.g., Italian - `it`) is straightforward. Follow these four structured steps:
|
||||
|
||||
### Step 1: Create the Translation Dictionary
|
||||
Create a new JSON file inside the locales directory named `[lang].json` (e.g., `website/locales/it.json`).
|
||||
1. Copy the structure of [`website/locales/en.json`](file:///Users/koala/Documents/KoalaPlay/website/locales/en.json) to use as your baseline.
|
||||
2. Translate all string values while keeping the JSON keys identical.
|
||||
3. Configure the language metadata keys at the top of the file:
|
||||
```json
|
||||
{
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"CANONICAL_PATH": "it/",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Register in the Compiler
|
||||
Open the static site generator script [`website/build.js`](file:///Users/koala/Documents/KoalaPlay/website/build.js) and append your new language code to the active `languages` array:
|
||||
```javascript
|
||||
// Add 'it' to the array
|
||||
const languages = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it'];
|
||||
```
|
||||
|
||||
### Step 3: Run the Build Script
|
||||
Execute the compiler from the root of the repository:
|
||||
```bash
|
||||
node website/build.js
|
||||
```
|
||||
The compiler will automatically:
|
||||
1. Load the new JSON translation file.
|
||||
2. Create the target subdirectory `/website/www/it/`.
|
||||
3. Generate the compiled `/website/www/it/index.html` landing page, injecting correct relative assets and canonical metadata.
|
||||
|
||||
### Step 4: Update the Dashboard
|
||||
Open this `TRANSLATION.md` file and add your language to the **Supported Languages Dashboard** table, marking it as `Auto-Generated` (unless manually verified).
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future Roadmap: Dynamic Utility Pages
|
||||
|
||||
For pages that require fully dynamic, client-side interactions (like the room invitation bridge [`join.html`](file:///Users/koala/Documents/KoalaPlay/website/join.html)), we need to scale to unlimited languages without bloating the HTML size or polluting the URL.
|
||||
|
||||
### Clean Client-Side i18n Architecture
|
||||
|
||||
To maintain zero URL pollution (e.g. keeping invitation links clean as `/join.html#join:roomID:password`), we propose an **asynchronous JSON dictionary injection architecture**:
|
||||
|
||||
#### 1. Page Lifecycle Flow
|
||||
1. **User Landing:** The guest enters `/join.html` with a shared hash.
|
||||
2. **Language Resolution:** `lang-init.js` immediately reads their saved preference (`localStorage` or `navigator.language`) and applies the active class (e.g. `html.lang = "es"`).
|
||||
3. **Async Fetching:** A client-side loader script (`i18n-client.js`) runs asynchronously, downloading the correct dictionary (`fetch("/locales/es.json")`).
|
||||
4. **DOM Translation:** The script scans the page for elements carrying a `data-i18n` attribute and safely updates their text content at runtime, avoiding dual-text nodes and stylesheet recalculations.
|
||||
|
||||
#### 2. Declarative HTML Markup
|
||||
Elements are defined with custom data attributes specifying translation keys. English text is placed as the static HTML fallback:
|
||||
```html
|
||||
<h1 data-i18n="JOIN_TITLE">Ready to sync?</h1>
|
||||
<p id="join-desc" data-i18n="JOIN_SUBTITLE">You've been invited to join a session.</p>
|
||||
```
|
||||
|
||||
#### 3. Zero-Dependency Engine (`i18n-client.js`)
|
||||
```javascript
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 1. Recover the language determined during early initialization
|
||||
const activeLang = document.documentElement.lang || 'en';
|
||||
if (activeLang === 'en') return; // Default markup is already in English
|
||||
|
||||
// 2. Fetch the corresponding locale JSON asynchronously
|
||||
try {
|
||||
const response = await fetch(`locales/${activeLang}.json`);
|
||||
if (!response.ok) throw new Error('Locale file unavailable');
|
||||
const dictionary = await response.json();
|
||||
|
||||
// 3. Scan and translate data-i18n attributes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (dictionary[key]) {
|
||||
if (el.tagName === 'IMG') {
|
||||
el.alt = dictionary[key];
|
||||
} else {
|
||||
el.textContent = dictionary[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Dynamic i18n loading failed. Defaulting to English:', err);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Core Benefits
|
||||
* **Zero URL Pollution:** Keeps invitation hashes private and avoids messy query parameters (`?lang=de`), protecting user privacy.
|
||||
* **Optimal Performance:** Eliminates duplicate hidden text blocks, cutting page weight in half and ensuring smooth rendering.
|
||||
* **Infinite Scale:** Adding new languages to dynamic pages requires zero edits to HTML markup; the engine simply fetches new JSON dictionaries on-demand.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Extension Internationalization (i18n)
|
||||
|
||||
In **v2.0**, we extended full internationalization support to the **Browser Extension itself**. The architecture mirrors our web-based dynamic localization model to maintain complete parity.
|
||||
|
||||
* **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)
|
||||
* [`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)
|
||||
|
||||
### ⚙️ How it Works inside the Extension
|
||||
|
||||
1. **System Locale Auto-Detection**: On first run, the extension detects the browser system language using `navigator.language` or `chrome.i18n.getUILanguage()`.
|
||||
2. **On-the-Fly Redraws**: When the user selects a different language in the settings tab (`#langSelector`), the selection is stored in `chrome.storage.sync` and the translation engine immediately triggers `translateDOM()`. The interface, empty state cards, tooltips, dynamic onboarding tutorial guides, and status badges re-render instantly without reloading the popup.
|
||||
3. **Localized System Notifications**: On play, pause, or seek commands, `background.js` retrieves the user's active locale preference from storage, loads the correct dictionary, and pushes native OS notifications fully translated.
|
||||
|
||||
### 🧪 Auditing & Sync Checks
|
||||
|
||||
To ensure that no language dictionary falls out of sync (causing missing labels or blank interfaces), developers must run the locale auditor tool before packaging releases:
|
||||
```bash
|
||||
node scripts/test-locales.js
|
||||
```
|
||||
This script asserts that all JSON dictionary files under `extension/locales/` share exactly the same set of keys as the English baseline (`en.json`).
|
||||
|
||||
@@ -241,6 +241,14 @@ async function compile() {
|
||||
console.log(' Assets copied.');
|
||||
}
|
||||
|
||||
// Copy apple-touch-icon to www root (browsers/iOS request these at /)
|
||||
const appleTouchSrc = path.join(destAssets, 'apple-touch-icon.png');
|
||||
if (fs.existsSync(appleTouchSrc)) {
|
||||
fs.copyFileSync(appleTouchSrc, path.join(wwwDir, 'apple-touch-icon.png'));
|
||||
fs.copyFileSync(appleTouchSrc, path.join(wwwDir, 'apple-touch-icon-precomposed.png'));
|
||||
console.log(' ✓ Apple touch icons copied to www root.');
|
||||
}
|
||||
|
||||
// ── 7. Convert all WebP to AVIF (quality 70) ──
|
||||
console.log('Converting WebP → AVIF...');
|
||||
let avifCount = 0;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Schaue Netflix, Emby, Jellyfin, YouTube, Twitch und fast jedes HTML5-Video perfekt synchronisiert mit Freunden. Datenschutzfreundliche Browser-Erweiterung für Chrome und Firefox.",
|
||||
"NAV_FEATURES": "Funktionen",
|
||||
"NAV_HOW_IT_WORKS": "So funktioniert's",
|
||||
"HERO_TITLE": "Gemeinsam schauen.<br>Perfekt synchron.",
|
||||
"HERO_TITLE": "Gemeinsam schauen.<span class='hero-line2'>Perfekt synchron.</span>",
|
||||
"HERO_SUBTITLE": "Dein Kino-Abend auf Distanz. Keine Lags, keine Anmeldung. Einfach Link teilen und zusammen schauen.",
|
||||
"HERO_MASCOT_ALT": "Ein niedlicher Koala steht da und schaut nach unten auf die Download-Buttons",
|
||||
"ADD_TO_CHROME": "Zu Chrome hinzufügen",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Keine Accounts / Datenschutz",
|
||||
"FEATURE_3_DESC": "Kein Login. Keine Daten. Einfach Link teilen und schauen. Der Server läuft flüchtig im RAM und löscht deinen Raum komplett nach dem Verlassen.",
|
||||
"FEATURE_4_TITLE": "Universeller HTML5-Support",
|
||||
"FEATURE_4_DESC": "Unterstützt YouTube, Twitch, Netflix, Jellyfin, Emby und fast jede beliebige Webseite mit einem HTML5-Video-Tag. Ideal auch für eigene Medienbibliotheken.",
|
||||
"FEATURE_4_DESC": "Unterstützt YouTube, Twitch, Netflix, Jellyfin, Emby und fast jede beliebige Webseite mit einem HTML5-Video-Tag. Ideal auch für eigene Medienbibliotheken. Es werden keinerlei Videodaten an unsere Server gesendet — nur Synchronisations-Metadaten.",
|
||||
"FEATURE_5_TITLE": "Self-Hostable & Docker-Ready",
|
||||
"FEATURE_5_DESC": "Unsere Server sind kostenlos und schnell, aber du kannst auch die volle Kontrolle übernehmen, wenn du willst. Starte dein eigenes privates Relay in Sekunden via Docker.",
|
||||
"FEATURE_6_TITLE": "Direkte Einladungen & 1-Klick Beitritt",
|
||||
"FEATURE_6_DESC": "Keine lästigen IPs oder Passwörter austauschen. Teile einfach einen Einladungslink mit deinen Freunden, um sie mit einem Klick in den Raum zu holen.",
|
||||
"FEATURE_7_TITLE": "Lautstärke Angleichen / Audio Compressor",
|
||||
"FEATURE_7_DESC": "Nie wieder am Lautstärkeregler spielen. Der eingebaute Audio Compressor verstärkt leise Dialoge und zähmt laute Explosionen. Funktioniert auf Netflix, Prime Video, Disney+, YouTube und jedem HTML5-Video. Komplett lokal — keine Audiodaten verlassen deinen Computer.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT-Lizenz)",
|
||||
"FEATURE_8_DESC": "Der gesamte Quellcode ist auf GitHub öffentlich einsehbar. Keine Black Boxes, keine proprietären Teile — 100 % Transparenz vom Relay-Server bis zur Browser-Erweiterung.",
|
||||
"COMP_TITLE": "KoalaSync vs. Teleparty",
|
||||
"COMP_SUBTITLE": "Erfahre, warum Open Source, Werbefreiheit und echter Datenschutz die bessere Wahl für gemeinsames Schauen sind.",
|
||||
"COMP_COL_FEATURE": "Funktion",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Verfügbare Oberflächensprachen und Übersetzungsunterstützung.",
|
||||
"COMP_FEAT_6_KOALA": "13 Sprachen (wachsend)",
|
||||
"COMP_FEAT_6_TELE": "Nur Englisch",
|
||||
"COMP_FEAT_7_NAME": "Audio Compressor / Lautstärke Angleichen",
|
||||
"COMP_FEAT_7_DESC": "Integrierte Audionormalisierung für leise Dialoge und laute Effekte",
|
||||
"COMP_FEAT_7_KOALA": "Ja (4 Presets, Dry/Wet-Regelung)",
|
||||
"COMP_FEAT_7_TELE": "Nein",
|
||||
"COMP_FOOTNOTE_1": "Stand des Vergleichs: Mai 2026.",
|
||||
"COMP_FOOTNOTE_2": "Details zu Preisen und unterstützten Netzwerken von Teleparty Premium:",
|
||||
"COMP_FOOTNOTE_3": "Datenschutzerklärung und Tracker-Erfassung von Teleparty:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Ja, jeder Teilnehmer braucht KoalaSync — aber ohne Anmeldung oder Kontoerstellung. Einfach auf den Einladungslink klicken, die Erweiterung installieren (falls nicht vorhanden) und du bist automatisch im Raum.",
|
||||
"FAQ_Q6": "Sind meine Daten sicher? Was, wenn ich dem offiziellen Server nicht traue?",
|
||||
"FAQ_A6": "Absolut sicher. Der KoalaSync Relay-Server läuft ausschließlich im RAM — alle Sitzungsdaten werden sofort gelöscht, sobald du den Raum verlässt. Keine Datenbank, keine Protokolle, keine Analysen, keine Benutzerkonten. Wenn du volle Kontrolle möchtest: hoste deinen eigenen privaten Relay-Server via Docker in wenigen Minuten. Der gesamte Quellcode ist Open Source und auf GitHub einsehbar.",
|
||||
"FAQ_Q7": "Kann ich leise Dialoge und laute Explosionen beim Videoschauen im Browser ausgleichen?",
|
||||
"FAQ_A7": "Ja. KoalaSync 2.2.0 enthält einen eingebauten Audio Compressor, der den Unterschied zwischen lauten und leisen Passagen reduziert. Er läuft lokal über die Web Audio API — keine Audiodaten verlassen deinen Computer. Öffne das Popup, erstelle einen Raum, gehe zu Einstellungen → Audio Processing, schalte den Compressor ein und wähle ein Preset. Funktioniert auf Netflix, Prime Video, Disney+, YouTube und allen HTML5-Video-Seiten.",
|
||||
"HOWTO_TITLE": "So synchronisierst du Videos mit Freunden mit KoalaSync",
|
||||
"HOWTO_DESC": "Schaue Videos perfekt synchron mit Freunden auf YouTube, Netflix, Twitch und mehr. Keine Anmeldung, kein Tracking.",
|
||||
"HOWTO_STEP_1_NAME": "Browser-Erweiterung installieren",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "LÖSCHEN",
|
||||
"FOOTER_LEGAL_LINK": "de/impressum",
|
||||
"FOOTER_PRIVACY_LINK": "de/datenschutz",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.",
|
||||
"FOOTER_SUPPORT": "Kauf mir einen Kaffee auf Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Watch Netflix, Emby, Jellyfin, YouTube, Twitch and almost any HTML5 video in perfect sync with friends. Privacy-first, open-source browser extension for Chrome and Firefox.",
|
||||
"NAV_FEATURES": "Features",
|
||||
"NAV_HOW_IT_WORKS": "How it works",
|
||||
"HERO_TITLE": "Watch Together.<br>Sync Perfectly.",
|
||||
"HERO_TITLE": "Watch Together.<span class='hero-line2'>Sync Perfectly.</span>",
|
||||
"HERO_SUBTITLE": "Your remote movie night without lags. No registration, no data collection. Just share a link and watch together.",
|
||||
"HERO_MASCOT_ALT": "A cute koala standing and looking down at the download buttons",
|
||||
"ADD_TO_CHROME": "Add to Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Zero Accounts / Pure Privacy",
|
||||
"FEATURE_3_DESC": "No registration, no tracking, no persistency footprints. The server runs entirely in ephemeral RAM and purges your room when you leave.",
|
||||
"FEATURE_4_TITLE": "Universal HTML5 Support",
|
||||
"FEATURE_4_DESC": "Works on YouTube, Twitch, Netflix, Jellyfin, Emby, and almost any standard webpage containing a HTML5 video element. Also compatible with custom self-hosted setups.",
|
||||
"FEATURE_4_DESC": "Works on YouTube, Twitch, Netflix, Jellyfin, Emby, and almost any standard webpage containing a HTML5 video element. Also compatible with custom self-hosted setups. No video data is ever sent to our servers — only synchronization metadata.",
|
||||
"FEATURE_5_TITLE": "Self-Hostable & Docker-Ready",
|
||||
"FEATURE_5_DESC": "Our official servers are free and fast, but you can take full ownership if you want to. Launch your own private relay server in seconds via Docker.",
|
||||
"FEATURE_6_TITLE": "Instant Invites / 1-Click Join",
|
||||
"FEATURE_6_DESC": "No IP addresses or passwords to exchange. Share a generated invite link with your friends to let them join your room automatically with a single click.",
|
||||
"FEATURE_7_TITLE": "Volume Leveling / Audio Compressor",
|
||||
"FEATURE_7_DESC": "No more riding the volume knob. Built-in audio compressor boosts quiet dialogue and tames loud explosions. Works on Netflix, Prime Video, Disney+, YouTube, and any HTML5 video. Completely local — no audio data leaves your computer.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"COMP_TITLE": "KoalaSync vs. Teleparty",
|
||||
"COMP_SUBTITLE": "See why open source, ad-free and privacy-first is the superior way to watch together.",
|
||||
"COMP_COL_FEATURE": "Feature",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Available interface languages and translation support.",
|
||||
"COMP_FEAT_6_KOALA": "13 Languages (and growing)",
|
||||
"COMP_FEAT_6_TELE": "English only",
|
||||
"COMP_FEAT_7_NAME": "Audio Compressor / Volume Leveling",
|
||||
"COMP_FEAT_7_DESC": "Built-in audio normalization to balance quiet dialogue and loud effects",
|
||||
"COMP_FEAT_7_KOALA": "Yes (4 presets, dry/wet control)",
|
||||
"COMP_FEAT_7_TELE": "No",
|
||||
"COMP_FOOTNOTE_1": "Comparison state: May 2026.",
|
||||
"COMP_FOOTNOTE_2": "Official Teleparty Premium pricing and supported networks details:",
|
||||
"COMP_FOOTNOTE_3": "Teleparty privacy policies and tracking data collection:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Yes, each participant needs KoalaSync installed — but there's no sign-up or account creation. Simply click the invitation link, install the extension if you haven't already, and you'll join the room automatically in seconds.",
|
||||
"FAQ_Q6": "Is my data safe? What if I don't trust the official server?",
|
||||
"FAQ_A6": "Completely safe. KoalaSync's relay server is RAM-only — all session data is permanently deleted the moment you leave the room. No database, no logging, no analytics, no accounts. And if you prefer full control: you can self-host your own private relay server via Docker in minutes. The entire codebase is open source and auditable on GitHub.",
|
||||
"FAQ_Q7": "Can I fix quiet dialogue and loud explosions when watching videos in the browser?",
|
||||
"FAQ_A7": "Yes. KoalaSync 2.2.0 includes a built-in audio compressor that reduces the gap between quiet and loud parts of your video. It runs locally via the Web Audio API — no audio data ever leaves your computer. Open the popup, create a room, go to Settings → Audio Processing, toggle the compressor on, and pick a preset. Works on Netflix, Prime Video, Disney+, YouTube, and any HTML5 video site.",
|
||||
"HOWTO_TITLE": "How to synchronize videos with friends using KoalaSync",
|
||||
"HOWTO_DESC": "Watch any video in perfect sync with friends across YouTube, Netflix, Twitch and more. No sign-up, no tracking.",
|
||||
"HOWTO_STEP_1_NAME": "Install the browser extension",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "CLEAR",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.",
|
||||
"FOOTER_SUPPORT": "Buy me a coffee on Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Mira Netflix, Emby, Jellyfin, YouTube, Twitch y casi cualquier video HTML5 en perfecta sincronización con amigos. Extensión de navegador de código abierto y respetuosa con la privacidad para Chrome y Firefox.",
|
||||
"NAV_FEATURES": "Características",
|
||||
"NAV_HOW_IT_WORKS": "Cómo funciona",
|
||||
"HERO_TITLE": "Miren juntos.<br>Sincronización perfecta.",
|
||||
"HERO_TITLE": "Miren juntos.<span class='hero-line2'>Sincronización perfecta.</span>",
|
||||
"HERO_SUBTITLE": "Tu noche de películas a distancia sin retrasos. Sin registro ni recopilación de datos. Solo comparte un enlace y miren juntos.",
|
||||
"HERO_MASCOT_ALT": "Un lindo koala de pie mirando hacia abajo a los botones de descarga",
|
||||
"ADD_TO_CHROME": "Añadir a Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Sin cuentas / Privacidad absoluta",
|
||||
"FEATURE_3_DESC": "Sin registros, sin seguimiento, sin almacenamiento de datos. El servidor funciona completamente en RAM efímera y elimina tu sala tan pronto como sales.",
|
||||
"FEATURE_4_TITLE": "Soporte universal HTML5",
|
||||
"FEATURE_4_DESC": "Funciona en YouTube, Twitch, Netflix, Jellyfin, Emby y casi cualquier página web estándar que contenga un elemento de video HTML5. También es compatible con configuraciones personalizadas.",
|
||||
"FEATURE_4_DESC": "Funciona en YouTube, Twitch, Netflix, Jellyfin, Emby y casi cualquier página web estándar que contenga un elemento de video HTML5. También es compatible con configuraciones personalizadas. Nunca se envían datos de vídeo a nuestros servidores, solo metadatos de sincronización.",
|
||||
"FEATURE_5_TITLE": "Auto-alojable y listo para Docker",
|
||||
"FEATURE_5_DESC": "Nuestros servidores oficiales son gratuitos y rápidos, pero puedes tomar el control total si lo deseas. Inicia tu propio servidor de retransmisión privado en segundos a través de Docker.",
|
||||
"FEATURE_6_TITLE": "Invitaciones al instante / Unirse en 1 clic",
|
||||
"FEATURE_6_DESC": "Sin direcciones IP o contraseñas que intercambiar. Comparte un enlace de invitación generado para permitir que tus amigos se unan automáticamente a tu sala con un solo clic.",
|
||||
"FEATURE_7_TITLE": "Nivelación de volumen / Compresor de audio",
|
||||
"FEATURE_7_DESC": "No más usar el control de volumen constantemente. El compresor de audio integrado realza los diálogos bajos y controla las explosiones fuertes. Funciona en Netflix, Prime Video, Disney+, YouTube y cualquier video HTML5. Completamente local — ningún dato de audio sale de tu ordenador.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"COMP_TITLE": "KoalaSync vs Teleparty",
|
||||
"COMP_SUBTITLE": "Descubre por qué una herramienta de código abierto, sin anuncios y respetuosa con la privacidad es la mejor opción para ver juntos.",
|
||||
"COMP_COL_FEATURE": "Característica",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Idiomas de interfaz disponibles y soporte de traducción.",
|
||||
"COMP_FEAT_6_KOALA": "13 idiomas (y creciendo)",
|
||||
"COMP_FEAT_6_TELE": "Solo inglés",
|
||||
"COMP_FEAT_7_NAME": "Compresor de Audio / Nivelación de Volumen",
|
||||
"COMP_FEAT_7_DESC": "Normalización de audio integrada para equilibrar diálogos bajos y efectos fuertes",
|
||||
"COMP_FEAT_7_KOALA": "Sí (4 ajustes preestablecidos, control dry/wet)",
|
||||
"COMP_FEAT_7_TELE": "No",
|
||||
"COMP_FOOTNOTE_1": "Estado de la comparación: mayo de 2026.",
|
||||
"COMP_FOOTNOTE_2": "Detalles oficiales de precios de Teleparty Premium y redes compatibles:",
|
||||
"COMP_FOOTNOTE_3": "Políticas de privacidad y recopilación de datos de seguimiento de Teleparty:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Sí, cada participante necesita KoalaSync — pero sin registro ni creación de cuenta. Solo haz clic en el enlace de invitación, instala la extensión si es necesario y te unes automáticamente a la sala.",
|
||||
"FAQ_Q6": "¿Están seguros mis datos? ¿Y si no confío en el servidor oficial?",
|
||||
"FAQ_A6": "Totalmente seguro. El servidor relay de KoalaSync usa solo RAM — todos los datos de sesión se eliminan al salir de la sala. Sin base de datos, sin registros, sin análisis, sin cuentas. Si prefieres control total: aloja tu propio servidor relay privado con Docker en minutos. El código fuente es completamente abierto y auditable en GitHub.",
|
||||
"FAQ_Q7": "¿Puedo arreglar diálogos bajos y explosiones fuertes al ver videos en el navegador?",
|
||||
"FAQ_A7": "Sí. KoalaSync 2.2.0 incluye un compresor de audio integrado que reduce la diferencia entre las partes bajas y altas de tu video. Funciona localmente a través de la API Web Audio — ningún dato de audio sale de tu ordenador. Abre el popup, crea una sala, ve a Ajustes → Audio Processing, activa el compresor y elige un preset. Funciona en Netflix, Prime Video, Disney+, YouTube y cualquier sitio de video HTML5.",
|
||||
"HOWTO_TITLE": "Cómo sincronizar videos con amigos usando KoalaSync",
|
||||
"HOWTO_DESC": "Mira videos en perfecta sincronía con amigos en YouTube, Netflix, Twitch y más. Sin registro, sin seguimiento.",
|
||||
"HOWTO_STEP_1_NAME": "Instalar la extensión",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "CLEAR",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync no está afiliado, respaldado ni asociado con Netflix, Disney+, Amazon, YouTube, Twitch ni ninguna otra plataforma de streaming. Todas las marcas comerciales son propiedad de sus respectivos dueños.",
|
||||
"FOOTER_SUPPORT": "Invítame a un café en Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Regardez Netflix, Emby, Jellyfin, YouTube, Twitch et presque n'importe quelle vidéo HTML5 en parfaite synchronisation avec vos amis. Extension de navigateur open-source et respectueuse de la vie privée pour Chrome et Firefox.",
|
||||
"NAV_FEATURES": "Fonctionnalités",
|
||||
"NAV_HOW_IT_WORKS": "Comment ça marche",
|
||||
"HERO_TITLE": "Regardez ensemble.<br>Synchronisation parfaite.",
|
||||
"HERO_TITLE": "Regardez ensemble.<span class='hero-line2'>Synchronisation parfaite.</span>",
|
||||
"HERO_SUBTITLE": "Votre soirée cinéma à distance sans décalage. Pas d'inscription, pas de collecte de données. Partagez simplement un lien et regardez ensemble.",
|
||||
"HERO_MASCOT_ALT": "Un koala mignon debout et regardant vers le bas les boutons de téléchargement",
|
||||
"ADD_TO_CHROME": "Ajouter à Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Aucun compte / Vie privée garantie",
|
||||
"FEATURE_3_DESC": "Pas d'inscription, pas de suivi, pas de stockage de données. Le serveur fonctionne entièrement dans une RAM éphémère et supprime votre salon dès votre départ.",
|
||||
"FEATURE_4_TITLE": "Support HTML5 universel",
|
||||
"FEATURE_4_DESC": "Fonctionne sur YouTube, Twitch, Netflix, Jellyfin, Emby et presque n'importe quelle page web standard contenant un élément vidéo HTML5. Également compatible avec les installations personnalisées.",
|
||||
"FEATURE_4_DESC": "Fonctionne sur YouTube, Twitch, Netflix, Jellyfin, Emby et presque n'importe quelle page web standard contenant un élément vidéo HTML5. Également compatible avec les installations personnalisées. Aucune donnée vidéo n'est envoyée à nos serveurs — seules les métadonnées de synchronisation.",
|
||||
"FEATURE_5_TITLE": "Hébergeable soi-même & prêt pour Docker",
|
||||
"FEATURE_5_DESC": "Nos serveurs officiels sont gratuits et rapides, mais vous pouvez prendre le contrôle total si vous le souhaitez. Lancez votre propre serveur relais privé en quelques secondes via Docker.",
|
||||
"FEATURE_6_TITLE": "Invitations instantanées / Rejoindre en 1 clic",
|
||||
"FEATURE_6_DESC": "Pas d'adresses IP ou de mots de passe à échanger. Partagez un lien d'invitation généré pour permettre à vos amis de rejoindre automatiquement votre salon en un seul clic.",
|
||||
"FEATURE_7_TITLE": "Égalisation du volume / Compresseur audio",
|
||||
"FEATURE_7_DESC": "Fini de jouer avec le bouton du volume. Le compresseur audio intégré booste les dialogues discrets et adoucit les explosions. Fonctionne sur Netflix, Prime Video, Disney+, YouTube et toute vidéo HTML5. Entièrement local — aucune donnée audio ne quitte votre ordinateur.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"COMP_TITLE": "KoalaSync vs Teleparty",
|
||||
"COMP_SUBTITLE": "Découvrez pourquoi un outil open-source, sans publicité et respectueux de la vie privée est le meilleur choix pour regarder ensemble.",
|
||||
"COMP_COL_FEATURE": "Fonctionnalité",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Langues d'interface disponibles et support de traduction.",
|
||||
"COMP_FEAT_6_KOALA": "13 langues (et plus à venir)",
|
||||
"COMP_FEAT_6_TELE": "Anglais uniquement",
|
||||
"COMP_FEAT_7_NAME": "Compresseur Audio / Égalisation du Volume",
|
||||
"COMP_FEAT_7_DESC": "Normalisation audio intégrée pour équilibrer les dialogues silencieux et les effets sonores",
|
||||
"COMP_FEAT_7_KOALA": "Oui (4 préréglages, contrôle dry/wet)",
|
||||
"COMP_FEAT_7_TELE": "Non",
|
||||
"COMP_FOOTNOTE_1": "État de la comparaison : mai 2026.",
|
||||
"COMP_FOOTNOTE_2": "Détails sur les tarifs officiels de Teleparty Premium et les réseaux pris en charge :",
|
||||
"COMP_FOOTNOTE_3": "Politiques de confidentialité et collecte de données de suivi de Teleparty :",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Oui, chaque participant a besoin de KoalaSync — mais sans inscription ni création de compte. Cliquez simplement sur le lien d'invitation, installez l'extension si nécessaire et vous rejoignez automatiquement le salon.",
|
||||
"FAQ_Q6": "Mes données sont-elles en sécurité ? Et si je ne fais pas confiance au serveur officiel ?",
|
||||
"FAQ_A6": "Totalement sûr. Le serveur relay de KoalaSync fonctionne uniquement en RAM — toutes les données de session sont supprimées dès que vous quittez le salon. Aucune base de données, aucun journal, aucune analyse, aucun compte. Si vous préférez un contrôle total : hébergez votre propre serveur relay privé via Docker en quelques minutes. Le code source est entièrement open source et vérifiable sur GitHub.",
|
||||
"FAQ_Q7": "Puis-je atténuer les dialogues discrets et les explosions fortes en regardant des vidéos dans le navigateur ?",
|
||||
"FAQ_A7": "Oui. KoalaSync 2.2.0 inclut un compresseur audio intégré qui réduit l'écart entre les parties silencieuses et bruyantes de votre vidéo. Il fonctionne localement via l'API Web Audio — aucune donnée audio ne quitte votre ordinateur. Ouvrez le popup, créez un salon, allez dans Paramètres → Audio Processing, activez le compresseur et choisissez un préréglage. Fonctionne sur Netflix, Prime Video, Disney+, YouTube et tout site vidéo HTML5.",
|
||||
"HOWTO_TITLE": "Comment synchroniser des vidéos avec des amis avec KoalaSync",
|
||||
"HOWTO_DESC": "Regardez des vidéos en parfaite synchronisation avec vos amis sur YouTube, Netflix, Twitch et plus. Sans inscription, sans pistage.",
|
||||
"HOWTO_STEP_1_NAME": "Installer l'extension",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "CLEAR",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync n'est pas affilié à Netflix, Disney+, Amazon, YouTube, Twitch ou toute autre plateforme de streaming. Toutes les marques déposées appartiennent à leurs détenteurs respectifs.",
|
||||
"FOOTER_SUPPORT": "Offre-moi un café sur Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"CANONICAL_PATH": "it/",
|
||||
"LANG_TOGGLE_URL": "",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "it_IT",
|
||||
"META_TITLE": "KoalaSync | Sincronizza Netflix, YouTube e qualsiasi video con gli amici",
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Guarda Netflix, Emby, Jellyfin, YouTube, Twitch e quasi tutti i video HTML5 in perfetta sincronia con gli amici. Estensione del browser open source e attenta alla privacy per Chrome e Firefox.",
|
||||
"NAV_FEATURES": "Funzionalità",
|
||||
"NAV_HOW_IT_WORKS": "Come Funziona",
|
||||
"HERO_TITLE": "Guarda Insieme.<br>Sincronizza Perfettamente.",
|
||||
"HERO_TITLE": "Guarda Insieme.<span class='hero-line2'>Sincronizza Perfettamente.</span>",
|
||||
"HERO_SUBTITLE": "La tua serata cinema a distanza senza lag. Nessuna registrazione, nessun dato raccolto. Condividi un link e guarda insieme.",
|
||||
"HERO_MASCOT_ALT": "Un simpatico koala in piedi che guarda in basso verso i pulsanti di download",
|
||||
"ADD_TO_CHROME": "Aggiungi a Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Zero Account / Massima Privacy",
|
||||
"FEATURE_3_DESC": "Nessuna registrazione, nessun tracciamento, nessuna traccia persistente. Il server funziona interamente nella RAM effimera e cancella la tua stanza quando te ne vai.",
|
||||
"FEATURE_4_TITLE": "Supporto HTML5 Universale",
|
||||
"FEATURE_4_DESC": "Funziona su YouTube, Twitch, Netflix, Jellyfin, Emby e quasi tutte le pagine web standard contenenti un elemento video HTML5. Compatibile anche con server personalizzati.",
|
||||
"FEATURE_4_DESC": "Funziona su YouTube, Twitch, Netflix, Jellyfin, Emby e quasi tutte le pagine web standard contenenti un elemento video HTML5. Compatibile anche con server personalizzati. Nessun dato video viene mai inviato ai nostri server — solo metadati di sincronizzazione.",
|
||||
"FEATURE_5_TITLE": "Ospitabile Autonomamente e Pronto per Docker",
|
||||
"FEATURE_5_DESC": "I nostri server ufficiali sono gratuiti e veloci, ma puoi gestirne uno tuo se preferisci. Avvia il tuo server di inoltro privato in pochi secondi tramite Docker.",
|
||||
"FEATURE_6_TITLE": "Inviti Istantanei / Accesso con 1 Click",
|
||||
"FEATURE_6_DESC": "Nessun indirizzo IP o password da scambiare. Condividi un link di invito generato con i tuoi amici per farli entrare nella stanza automaticamente con un solo clic.",
|
||||
"FEATURE_7_TITLE": "Equalizzazione Volume / Compressore Audio",
|
||||
"FEATURE_7_DESC": "Basta regolare continuamente il volume. Il compressore audio integrato potenzia i dialoghi sommessi e attenua le esplosioni forti. Funziona su Netflix, Prime Video, Disney+, YouTube e qualsiasi video HTML5. Completamente locale — nessun dato audio lascia il tuo computer.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"COMP_TITLE": "KoalaSync vs. Teleparty",
|
||||
"COMP_SUBTITLE": "Scopri perché l'open source, senza pubblicità e attento alla privacy è il modo migliore per guardare i video insieme.",
|
||||
"COMP_COL_FEATURE": "Funzionalità",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Lingue dell'interfaccia disponibili e supporto alle traduzioni.",
|
||||
"COMP_FEAT_6_KOALA": "13 Lingue (in crescita)",
|
||||
"COMP_FEAT_6_TELE": "Solo Inglese",
|
||||
"COMP_FEAT_7_NAME": "Compressore Audio / Livellamento Volume",
|
||||
"COMP_FEAT_7_DESC": "Normalizzazione audio integrata per bilanciare dialoghi bassi ed effetti forti",
|
||||
"COMP_FEAT_7_KOALA": "Sì (4 preset, controllo dry/wet)",
|
||||
"COMP_FEAT_7_TELE": "No",
|
||||
"COMP_FOOTNOTE_1": "Stato del confronto: Maggio 2026.",
|
||||
"COMP_FOOTNOTE_2": "Dettagli sui prezzi di Teleparty Premium e reti supportate:",
|
||||
"COMP_FOOTNOTE_3": "Informativa sulla privacy di Teleparty e raccolta dei dati di tracciamento:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Sì, ogni partecipante deve avere KoalaSync installato — ma non è richiesta alcuna registrazione o creazione di account. Fai clic sul link di invito, installa l'estensione se non l'hai già fatto ed entrerai automaticamente nella stanza in pochi secondi.",
|
||||
"FAQ_Q6": "I miei dati sono al sicuro? E se non mi fido del server ufficiale?",
|
||||
"FAQ_A6": "Completamente al sicuro. Il server di inoltro di KoalaSync è solo in RAM — tutti i dati della sessione vengono eliminati permanentemente nel momento in cui lasci la stanza. Nessun database, nessun log, nessuna analisi, nessun account. E se preferisci il controllo totale: puoi ospitare autonomamente il tuo server di inoltro privato tramite Docker in pochi minuti. L'intera codebase è open source e controllabile su GitHub.",
|
||||
"FAQ_Q7": "Posso risolvere dialoghi sommessi ed esplosioni forti quando guardo video nel browser?",
|
||||
"FAQ_A7": "Sì. KoalaSync 2.2.0 include un compressore audio integrato che riduce la differenza tra le parti silenziose e quelle rumorose del tuo video. Funziona localmente tramite l'API Web Audio — nessun dato audio lascia mai il tuo computer. Apri il popup, crea una stanza, vai su Impostazioni → Audio Processing, attiva il compressore e scegli un preset. Funziona su Netflix, Prime Video, Disney+, YouTube e qualsiasi sito video HTML5.",
|
||||
"HOWTO_TITLE": "Come sincronizzare i video con gli amici usando KoalaSync",
|
||||
"HOWTO_DESC": "Guarda qualsiasi video in perfetta sincronia con gli amici su YouTube, Netflix, Twitch e altri. Nessuna registrazione, nessun tracciamento.",
|
||||
"HOWTO_STEP_1_NAME": "Installa l'estensione del browser",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "PULISCI",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync non è affiliato, approvato o associato a Netflix, Disney+, Amazon, YouTube, Twitch o qualsiasi altra piattaforma di streaming. Tutti i marchi registrati sono di proprietà dei rispettivi titolari.",
|
||||
"FOOTER_SUPPORT": "Offrimi un caffè su Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"LANG_CODE": "ja",
|
||||
"HTML_CLASS": "lang-ja",
|
||||
"CANONICAL_PATH": "ja/",
|
||||
"LANG_TOGGLE_URL": "",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "ja_JP",
|
||||
"META_TITLE": "KoalaSync | Netflix、YouTube、あらゆる動画を友達と同期視聴",
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Netflix、Emby、Jellyfin、YouTube、Twitch、ほぼすべてのHTML5動画を友達と完全に同期して視聴。ChromeおよびFirefox用のプライバシー優先のオープンソースブラウザ拡張機能。",
|
||||
"NAV_FEATURES": "機能",
|
||||
"NAV_HOW_IT_WORKS": "仕組み",
|
||||
"HERO_TITLE": "一緒に観よう。<br>完璧に同期。",
|
||||
"HERO_TITLE": "一緒に観よう。<span class='hero-line2'>完璧に同期。</span>",
|
||||
"HERO_SUBTITLE": "遅延のないリモート映画鑑賞。登録不要、データ収集なし。リンクを共有するだけで、一緒に視聴できます。",
|
||||
"HERO_MASCOT_ALT": "ダウンロードボタンを見下ろしているかわいいコアラ",
|
||||
"ADD_TO_CHROME": "Chromeに追加",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "アカウント不要 / 純粋なプライバシー",
|
||||
"FEATURE_3_DESC": "登録不要、追跡なし、履歴も残りません。サーバーは一時的なメモリ(RAM)上でのみ動作し、退出時にルームを完全に消去します。",
|
||||
"FEATURE_4_TITLE": "ユニバーサルHTML5サポート",
|
||||
"FEATURE_4_DESC": "YouTube、Twitch、Netflix、Jellyfin、Emby、およびHTML5ビデオ要素を含むほぼすべての標準的なWebページで動作します。独自のセルフホスト設定とも互換性があります。",
|
||||
"FEATURE_4_DESC": "YouTube、Twitch、Netflix、Jellyfin、Emby、およびHTML5ビデオ要素を含むほぼすべての標準的なWebページで動作します。独自のセルフホスト設定とも互換性があります。No video data is ever sent to our servers — only synchronization metadata.",
|
||||
"FEATURE_5_TITLE": "セルフホスト可能&Docker対応",
|
||||
"FEATURE_5_DESC": "公式サーバーは無料で高速ですが、必要に応じて独自のサーバーを構築できます。Dockerを使用して、数秒でプライベートリレーサーバーを起動できます。",
|
||||
"FEATURE_6_TITLE": "インスタント招待 / 1クリック参加",
|
||||
"FEATURE_6_DESC": "IPアドレスやパスワードをやり取りする必要はありません。生成された招待リンクを友達に共有するだけで、1クリックで自動的にルームに参加できます。",
|
||||
"FEATURE_7_TITLE": "音量均等化 / オーディオコンプレッサー",
|
||||
"FEATURE_7_DESC": "音量つまみをいじる必要はもうありません。内蔵オーディオコンプレッサーが小さな会話を強調し、大きな爆発音を抑えます。Netflix、Prime Video、Disney+、YouTube、あらゆるHTML5ビデオで動作。完全にローカル処理 — オーディオデータがコンピュータから送信されることはありません。",
|
||||
"FEATURE_8_TITLE": "オープンソース(MITライセンス)",
|
||||
"FEATURE_8_DESC": "すべてのコードベースはGitHubで公開されており、誰でも監査可能です。ブラックボックスや独自のバイナリデータ(blob)は一切なく、中継サーバーからブラウザ拡張機能まで100%の透明性を確保しています。",
|
||||
"COMP_TITLE": "KoalaSync と Teleparty の比較",
|
||||
"COMP_SUBTITLE": "オープンソース、広告なし、プライバシー優先が、一緒に動画を観る上でいかに優れているかをご覧ください。",
|
||||
"COMP_COL_FEATURE": "機能",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "利用可能なインターフェース言語と翻訳サポート。",
|
||||
"COMP_FEAT_6_KOALA": "13の言語(今後も追加予定)",
|
||||
"COMP_FEAT_6_TELE": "英語のみ",
|
||||
"COMP_FEAT_7_NAME": "オーディオコンプレッサー / 音量均等化",
|
||||
"COMP_FEAT_7_DESC": "静かな会話と大きな効果音のバランスを取る内蔵オーディオノーマライゼーション",
|
||||
"COMP_FEAT_7_KOALA": "はい(4プリセット、ドライ/ウェットコントロール)",
|
||||
"COMP_FEAT_7_TELE": "いいえ",
|
||||
"COMP_FOOTNOTE_1": "比較時点: 2026年5月。",
|
||||
"COMP_FOOTNOTE_2": "公式 Teleparty Premium の価格設定と対応ネットワークの詳細:",
|
||||
"COMP_FOOTNOTE_3": "Telepartyのプライバシーポリシーと追跡データの収集について:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "はい、参加者全員にKoalaSyncがインストールされている必要があります。ただし、登録やアカウント作成は不要です。招待リンクをクリックし、未インストールの場合は拡張機能をインストールすれば、数秒で自動的にルームに参加できます。",
|
||||
"FAQ_Q6": "データは安全ですか? 公式サーバーを信頼できない場合は?",
|
||||
"FAQ_A6": "完全に安全です。KoalaSyncのリレーサーバーはメモリ上のみで動作するため、ルームを退出した瞬間にすべてのセッションデータが永久に削除されます。データベース、ログ、分析、アカウントは一切ありません。さらに、完全に管理したい場合は、Dockerを介して独自のプライベートリレーサーバーを数分でセルフホストできます。コードベース全体がオープンソースであり、GitHubで監査可能です。",
|
||||
"FAQ_Q7": "ブラウザで動画を視聴する際、小さな会話と大きな爆発音を改善できますか?",
|
||||
"FAQ_A7": "はい。KoalaSync 2.2.0には、動画の静かな部分と騒がしい部分の差を減らす内蔵オーディオコンプレッサーが搭載されています。Web Audio APIを介してローカルで動作するため、オーディオデータがコンピュータから送信されることはありません。ポップアップを開き、ルームを作成し、設定 → Audio Processingに進み、コンプレッサーを有効にしてプリセットを選択してください。Netflix、Prime Video、Disney+、YouTube、あらゆるHTML5ビデオサイトで動作します。",
|
||||
"HOWTO_TITLE": "KoalaSyncを使用して友達とビデオを同期視聴する方法",
|
||||
"HOWTO_DESC": "YouTube、Netflix、Twitchなどで、友達と完全に同期してあらゆる動画を視聴。登録不要、追跡なし。",
|
||||
"HOWTO_STEP_1_NAME": "ブラウザ拡張機能をインストールする",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "消去",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSyncは、Netflix、Disney+、Amazon、YouTube、Twitch、またはその他のストリーミングプラットフォームと提携、承認、または関連付けられているものではありません。すべての商標はそれぞれの所有者に帰属します。",
|
||||
"FOOTER_SUPPORT": "Ko-Fiでコーヒーをおごる"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"LANG_CODE": "ko",
|
||||
"HTML_CLASS": "lang-ko",
|
||||
"CANONICAL_PATH": "ko/",
|
||||
"LANG_TOGGLE_URL": "",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "ko_KR",
|
||||
"META_TITLE": "KoalaSync | Netflix, YouTube 및 모든 비디오를 친구와 실시간 동기화 시청",
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch 및 거의 모든 HTML5 비디오를 친구들과 완벽하게 동기화하여 시청하세요. Chrome 및 Firefox용 개인정보 보호 중심 오픈 소스 브라우저 확장 프로그램.",
|
||||
"NAV_FEATURES": "특징",
|
||||
"NAV_HOW_IT_WORKS": "작동 방식",
|
||||
"HERO_TITLE": "함께 시청하세요.<br>완벽하게 동기화됩니다.",
|
||||
"HERO_TITLE": "함께 시청하세요.<span class='hero-line2'>완벽하게 동기화됩니다.</span>",
|
||||
"HERO_SUBTITLE": "지연 없는 원격 영화의 밤. 회원가입도, 데이터 수집도 없습니다. 링크를 공유하고 함께 시청하세요.",
|
||||
"HERO_MASCOT_ALT": "다운로드 버튼을 내려다보고 서 있는 귀여운 코알라",
|
||||
"ADD_TO_CHROME": "Chrome에 추가",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "계정 없음 / 완전한 개인정보 보호",
|
||||
"FEATURE_3_DESC": "회원가입 없음, 추적 없음, 흔적을 남기지 않습니다. 서버는 휘발성 RAM에서만 완전히 실행되며 방을 나가면 영구히 삭제됩니다.",
|
||||
"FEATURE_4_TITLE": "범용 HTML5 지원",
|
||||
"FEATURE_4_DESC": "YouTube, Twitch, Netflix, Jellyfin, Emby 및 HTML5 비디오 요소가 포함된 거의 모든 웹 페이지에서 작동합니다. 사용자 정의 자체 호스팅 설정과도 호환됩니다.",
|
||||
"FEATURE_4_DESC": "YouTube, Twitch, Netflix, Jellyfin, Emby 및 HTML5 비디오 요소가 포함된 거의 모든 웹 페이지에서 작동합니다. 사용자 정의 자체 호스팅 설정과도 호환됩니다. 비디오 데이터는 당사 서버로 절대 전송되지 않으며, 동기화 메타데이터만 전송됩니다.",
|
||||
"FEATURE_5_TITLE": "자체 호스팅 가능 및 Docker 지원",
|
||||
"FEATURE_5_DESC": "공식 서버는 무료이며 빠르지만, 원하는 경우 직접 호스팅할 수도 있습니다. Docker를 통해 몇 초 만에 나만의 개인 릴레이 서버를 구축해 보세요.",
|
||||
"FEATURE_6_TITLE": "즉시 초대 / 클릭 한 번으로 참여",
|
||||
"FEATURE_6_DESC": "IP 주소나 비밀번호를 교환할 필요가 없습니다. 생성된 초대 링크를 친구들과 공유하면 클릭 한 번으로 방에 자동으로 참여할 수 있습니다.",
|
||||
"FEATURE_7_TITLE": "볼륨 레벨링 / 오디오 압축기",
|
||||
"FEATURE_7_DESC": "더 이상 볼륨 노브를 계속 조정하지 마세요. 내장 오디오 압축기가 조용한 대화를 증폭하고 큰 폭발음을 완화합니다. Netflix, Prime Video, Disney+, YouTube 및 모든 HTML5 비디오에서 작동합니다. 완전히 로컬에서 처리 — 오디오 데이터가 컴퓨터를 떠나지 않습니다.",
|
||||
"FEATURE_8_TITLE": "오픈 소스 (MIT 라이선스)",
|
||||
"FEATURE_8_DESC": "모든 코드베이스는 GitHub에서 공개적으로 검토할 수 있습니다. 블랙박스나 독점적인 블롭(blob)이 전혀 없으며, 릴레이 서버부터 브라우저 확장 프로그램까지 100% 투명성을 보장합니다.",
|
||||
"COMP_TITLE": "KoalaSync 대 Teleparty",
|
||||
"COMP_SUBTITLE": "오픈 소스, 광고 없음, 개인정보 보호 중심의 서비스가 왜 함께 시청하기에 더 우수한지 확인해 보세요.",
|
||||
"COMP_COL_FEATURE": "기능",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "제공되는 인터페이스 언어 및 번역 지원 여부.",
|
||||
"COMP_FEAT_6_KOALA": "13개 언어 지원 (추가 중)",
|
||||
"COMP_FEAT_6_TELE": "영어만 지원",
|
||||
"COMP_FEAT_7_NAME": "오디오 압축기 / 볼륨 레벨링",
|
||||
"COMP_FEAT_7_DESC": "조용한 대화와 큰 효과음의 균형을 맞추는 내장 오디오 노멀라이제이션",
|
||||
"COMP_FEAT_7_KOALA": "예 (4개 프리셋, dry/wet 제어)",
|
||||
"COMP_FEAT_7_TELE": "아니요",
|
||||
"COMP_FOOTNOTE_1": "비교 기준일: 2026년 5월.",
|
||||
"COMP_FOOTNOTE_2": "공식 Teleparty Premium 요금제 및 지원되는 네트워크 세부 정보:",
|
||||
"COMP_FOOTNOTE_3": "Teleparty 개인정보 처리방침 및 추적 데이터 수집 정보:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "네, 각 참여자 모두 KoalaSync를 설치해야 합니다. 단, 가입이나 계정 생성은 필요하지 않습니다. 초대 링크를 클릭하고, 아직 설치하지 않았다면 확장 프로그램을 설치하면 몇 초 만에 자동으로 방에 참여하게 됩니다.",
|
||||
"FAQ_Q6": "제 데이터는 안전한가요? 공식 서버를 믿지 못하겠다면 어떻게 하나요?",
|
||||
"FAQ_A6": "완전히 안전합니다. KoalaSync의 릴레이 서버는 RAM 전용으로 작동하여, 방을 나가는 즉시 모든 세션 데이터가 영구적으로 삭제됩니다. 데이터베이스도, 로그도, 분석도, 계정도 없습니다. 완전한 제어를 원하신다면 Docker를 통해 몇 분 만에 나만의 개인 릴레이 서버를 직접 호스팅할 수 있습니다. 전체 소스 코드는 오픈 소스이며 GitHub에서 확인할 수 있습니다.",
|
||||
"FAQ_Q7": "브라우저에서 비디오를 시청할 때 조용한 대화와 큰 폭발음을 개선할 수 있나요?",
|
||||
"FAQ_A7": "네. KoalaSync 2.2.0에는 비디오의 조용한 부분과 시끄러운 부분 간의 차이를 줄이는 내장 오디오 압축기가 포함되어 있습니다. Web Audio API를 통해 로컬에서 실행되므로 오디오 데이터가 컴퓨터를 떠나지 않습니다. 팝업을 열고, 방을 만들고, 설정 → Audio Processing으로 이동하여 압축기를 켜고 프리셋을 선택하세요. Netflix, Prime Video, Disney+, YouTube 및 모든 HTML5 비디오 사이트에서 작동합니다.",
|
||||
"HOWTO_TITLE": "KoalaSync를 사용하여 친구들과 비디오를 동기화하는 방법",
|
||||
"HOWTO_DESC": "YouTube, Netflix, Twitch 등에서 친구들과 완벽하게 동기화하여 비디오를 시청하세요. 가입 없음, 추적 없음.",
|
||||
"HOWTO_STEP_1_NAME": "브라우저 확장 프로그램 설치",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "지우기",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync는 Netflix, Disney+, Amazon, YouTube, Twitch 또는 기타 스트리밍 플랫폼과 제휴, 보증 또는 연관되어 있지 않습니다. 모든 상표는 해당 소유자의 재산입니다.",
|
||||
"FOOTER_SUPPORT": "Ko-Fi에서 커피 한 잔 사주세요"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"LANG_CODE": "nl",
|
||||
"HTML_CLASS": "lang-nl",
|
||||
"CANONICAL_PATH": "nl/",
|
||||
"LANG_TOGGLE_URL": "",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "nl_NL",
|
||||
"META_TITLE": "KoalaSync | Synchroniseer Netflix, YouTube & elke video met vrienden",
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Bekijk Netflix, Emby, Jellyfin, YouTube, Twitch en bijna elke HTML5-video in perfecte synchronisatie met vrienden. Privacy-first, open-source browserextensie voor Chrome en Firefox.",
|
||||
"NAV_FEATURES": "Functies",
|
||||
"NAV_HOW_IT_WORKS": "Hoe het werkt",
|
||||
"HERO_TITLE": "Samen Kijken.<br>Perfect Synchroniseren.",
|
||||
"HERO_TITLE": "Samen Kijken.<span class='hero-line2'>Perfect Synchroniseren.</span>",
|
||||
"HERO_SUBTITLE": "Uw filmavond op afstand zonder vertraging. Geen registratie, geen gegevensverzameling. Deel gewoon een link en kijk samen.",
|
||||
"HERO_MASCOT_ALT": "Een schattige koala die staat en naar beneden kijkt naar de downloadknoppen",
|
||||
"ADD_TO_CHROME": "Toevoegen aan Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Geen accounts / Pure privacy",
|
||||
"FEATURE_3_DESC": "Geen registratie, geen tracking, geen permanente voetafdruk. De server draait volledig in het tijdelijke RAM en wist uw kamer wanneer u vertrekt.",
|
||||
"FEATURE_4_TITLE": "Universele HTML5-ondersteuning",
|
||||
"FEATURE_4_DESC": "Werkt op YouTube, Twitch, Netflix, Jellyfin, Emby en bijna elke standaard webpagina met een HTML5-video-element. Ook compatibel met aangepaste zelfgehoste installaties.",
|
||||
"FEATURE_4_DESC": "Werkt op YouTube, Twitch, Netflix, Jellyfin, Emby en bijna elke standaard webpagina met een HTML5-video-element. Ook compatibel met aangepaste zelfgehoste installaties. Er worden nooit videogegevens naar onze servers verzonden — alleen synchronisatiemetagegevens.",
|
||||
"FEATURE_5_TITLE": "Zelf te hosten & Docker-ready",
|
||||
"FEATURE_5_DESC": "Onze officiële servers zijn gratis en snel, maar u kunt de volledige controle nemen als u dat wilt. Start uw eigen privé-relayserver in seconden via Docker.",
|
||||
"FEATURE_6_TITLE": "Directe uitnodigingen / 1-klik deelname",
|
||||
"FEATURE_6_DESC": "Geen IP-adressen of wachtwoorden om uit te wisselen. Deel een gegenereerde uitnodigingslink met uw vrienden om ze met een enkele klik automatisch deel te laten nemen.",
|
||||
"FEATURE_7_TITLE": "Volume egaliseren / Audiocompressor",
|
||||
"FEATURE_7_DESC": "Niet langer aan de volumeknop zitten. De ingebouwde audiocompressor versterkt stille dialogen en dempt harde explosies. Werkt op Netflix, Prime Video, Disney+, YouTube en elke HTML5-video. Volledig lokaal — geen audiogegevens verlaten uw computer.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"COMP_TITLE": "KoalaSync vs. Teleparty",
|
||||
"COMP_SUBTITLE": "Ontdek waarom open-source, reclamevrij en privacy-first de superieure manier is om samen te kijken.",
|
||||
"COMP_COL_FEATURE": "Functie",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Beschikbare interfacetalen en vertaalondersteuning.",
|
||||
"COMP_FEAT_6_KOALA": "13 talen (en groeiend)",
|
||||
"COMP_FEAT_6_TELE": "Alleen Engels",
|
||||
"COMP_FEAT_7_NAME": "Audiocompressor / Volume Egaliseren",
|
||||
"COMP_FEAT_7_DESC": "Ingebouwde audionormalisatie om stille dialogen en luide effecten in balans te brengen",
|
||||
"COMP_FEAT_7_KOALA": "Ja (4 presets, dry/wet-regeling)",
|
||||
"COMP_FEAT_7_TELE": "Nee",
|
||||
"COMP_FOOTNOTE_1": "Vergelijkingstijdstip: Mei 2026.",
|
||||
"COMP_FOOTNOTE_2": "Officiële Teleparty Premium-prijzen en ondersteunde netwerkdetails:",
|
||||
"COMP_FOOTNOTE_3": "Teleparty privacybeleid en trackinggegevensverzameling:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Ja, elke deelnemer moet KoalaSync geïnstalleerd hebben — maar er is geen aanmelding of accountcreatie. Klik gewoon op de uitnodigingslink, installeer de extensie als u dat nog niet hebt gedaan, en u neemt binnen enkele seconden automatisch deel aan de kamer.",
|
||||
"FAQ_Q6": "Zijn mijn gegevens veilig? Wat als ik de officiële server niet vertrouw?",
|
||||
"FAQ_A6": "Volledig veilig. De relayserver van KoalaSync is alleen in het RAM-geheugen — alle sessiegegevens worden permanent verwijderd zodra u de kamer verlaat. Geen database, geen logboeken, geen analyses, geen accounts. En als u de voorkeur geeft aan volledige controle: u kunt uw eigen privé-relayserver binnen enkele minuten zelf hosten via Docker. De volledige codebase is open-source en auditeerbaar op GitHub.",
|
||||
"FAQ_Q7": "Kan ik stille dialogen en harde explosies oplossen bij het kijken van video's in de browser?",
|
||||
"FAQ_A7": "Ja. KoalaSync 2.2.0 bevat een ingebouwde audiocompressor die het verschil tussen stille en luidruchtige delen van uw video verkleint. Het werkt lokaal via de Web Audio API — geen audiogegevens verlaten ooit uw computer. Open de popup, maak een kamer, ga naar Instellingen → Audio Processing, schakel de compressor in en kies een preset. Werkt op Netflix, Prime Video, Disney+, YouTube en elke HTML5-videosite.",
|
||||
"HOWTO_TITLE": "Hoe video's te synchroniseren met vrienden via KoalaSync",
|
||||
"HOWTO_DESC": "Bekijk elke video in perfecte synchronisatie met vrienden op YouTube, Netflix, Twitch en meer. Geen aanmelding, geen tracking.",
|
||||
"HOWTO_STEP_1_NAME": "Installeer de browserextensie",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "WISSEN",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync is niet verbonden aan, goedgekeurd door of geassocieerd met Netflix, Disney+, Amazon, YouTube, Twitch of enig ander streamingplatform. Alle handelsmerken zijn eigendom van hun respectieve eigenaren.",
|
||||
"FOOTER_SUPPORT": "Trakteer op een koffie via Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"LANG_CODE": "pl",
|
||||
"HTML_CLASS": "lang-pl",
|
||||
"CANONICAL_PATH": "pl/",
|
||||
"LANG_TOGGLE_URL": "",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "pl_PL",
|
||||
"META_TITLE": "KoalaSync | Synchronizuj Netflix, YouTube i dowolne wideo ze znajomymi",
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Oglądaj Netflix, Emby, Jellyfin, YouTube, Twitch i prawie każde wideo HTML5 w idealnej synchronizacji ze znajomymi. Zorientowane na prywatność rozszerzenie o otwartym kodzie dla Chrome i Firefox.",
|
||||
"NAV_FEATURES": "Funkcje",
|
||||
"NAV_HOW_IT_WORKS": "Jak to działa",
|
||||
"HERO_TITLE": "Oglądajcie Razem.<br>Synchronizujcie Idealnie.",
|
||||
"HERO_TITLE": "Oglądajcie Razem.<span class='hero-line2'>Synchronizujcie Idealnie.</span>",
|
||||
"HERO_SUBTITLE": "Twój zdalny wieczór filmowy bez opóźnień. Bez rejestracji, bez zbierania danych. Po prostu udostępnij link i oglądajcie razem.",
|
||||
"HERO_MASCOT_ALT": "Uroczy koala stojący i patrzący w dół na przyciski pobierania",
|
||||
"ADD_TO_CHROME": "Dodaj do Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Zero kont / Czysta prywatność",
|
||||
"FEATURE_3_DESC": "Bez rejestracji, bez śledzenia, bez trwałych śladów. Serwer działa w całości w pamięci RAM i czyści Twój pokój, gdy go opuszczasz.",
|
||||
"FEATURE_4_TITLE": "Uniwersalne wsparcie HTML5",
|
||||
"FEATURE_4_DESC": "Działa na YouTube, Twitch, Netflix, Jellyfin, Emby i prawie każdej standardowej stronie z elementem wideo HTML5. Kompatybilny również z własnymi serwerami.",
|
||||
"FEATURE_4_DESC": "Działa na YouTube, Twitch, Netflix, Jellyfin, Emby i prawie każdej standardowej stronie z elementem wideo HTML5. Kompatybilny również z własnymi serwerami. Żadne dane wideo nigdy nie są wysyłane na nasze serwery — jedynie metadane synchronizacji.",
|
||||
"FEATURE_5_TITLE": "Możliwość własnego hostowania i wsparcie Docker",
|
||||
"FEATURE_5_DESC": "Nasze oficjalne serwery są darmowe i szybkie, ale możesz przejąć pełną kontrolę, jeśli chcesz. Uruchom własny serwer w kilka sekund za pomocą Dockera.",
|
||||
"FEATURE_6_TITLE": "Natychmiastowe zaproszenia / Dołączanie 1 kliknięciem",
|
||||
"FEATURE_6_DESC": "Bez wymiany adresów IP czy haseł. Udostępnij wygenerowany link zaproszenia znajomym, aby mogli automatycznie dołączyć do pokoju jednym kliknięciem.",
|
||||
"FEATURE_7_TITLE": "Wyrównywanie głośności / Kompresor audio",
|
||||
"FEATURE_7_DESC": "Koniec z ciągłym regulowaniem głośności. Wbudowany kompresor audio wzmacnia ciche dialogi i łagodzi głośne eksplozje. Działa na Netflix, Prime Video, Disney+, YouTube i każdym filmie HTML5. W pełni lokalny — żadne dane audio nie opuszczają twojego komputera.",
|
||||
"FEATURE_8_TITLE": "Open Source (Licencja MIT)",
|
||||
"FEATURE_8_DESC": "Cały kod źródłowy jest publicznie dostępny i możliwy do zweryfikowania na GitHubie. Żadnych czarnych skrzynek, żadnych własnościowych obiektów blob — 100% przejrzystości od serwera przekaźnikowego (relay server) po rozszerzenie przeglądarki.",
|
||||
"COMP_TITLE": "KoalaSync vs. Teleparty",
|
||||
"COMP_SUBTITLE": "Zobacz, dlaczego otwarty kod, brak reklam i ochrona prywatności to lepszy sposób na wspólne oglądanie.",
|
||||
"COMP_COL_FEATURE": "Funkcja",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Dostępne języki interfejsu i wsparcie tłumaczeń.",
|
||||
"COMP_FEAT_6_KOALA": "13 języków (i rośnie)",
|
||||
"COMP_FEAT_6_TELE": "Tylko angielski",
|
||||
"COMP_FEAT_7_NAME": "Kompresor Audio / Wyrównywanie Głośności",
|
||||
"COMP_FEAT_7_DESC": "Wbudowana normalizacja dźwięku do równoważenia cichych dialogów i głośnych efektów",
|
||||
"COMP_FEAT_7_KOALA": "Tak (4 presetów, kontrola dry/wet)",
|
||||
"COMP_FEAT_7_TELE": "Nie",
|
||||
"COMP_FOOTNOTE_1": "Stan porównania: Maj 2026.",
|
||||
"COMP_FOOTNOTE_2": "Szczegóły oficjalnych cen Teleparty Premium i obsługiwanych sieci:",
|
||||
"COMP_FOOTNOTE_3": "Polityka prywatności Teleparty i zbieranie danych śledzenia:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Tak, każdy uczestnik musi mieć zainstalowane KoalaSync — ale nie ma rejestracji ani tworzenia kont. Po prostu kliknij link zaproszenia, zainstaluj rozszerzenie, jeśli jeszcze tego nie zrobiłeś, a dołączysz do pokoju automatycznie w kilka sekund.",
|
||||
"FAQ_Q6": "Czy moje dane są bezpieczne? Co jeśli nie ufam oficjalnemu serwerowi?",
|
||||
"FAQ_A6": "Całkowicie bezpieczne. Serwer KoalaSync działa tylko w pamięci RAM — wszystkie dane sesji są trwale usuwane w momencie opuszczenia pokoju. Brak bazy danych, brak logowania, brak analityki, brak kont. A jeśli wolisz pełną kontrolę: możesz samodzielmente uruchomić własny serwer przez Dockera w kilka minut. Cały kod źródłowy jest otwarty i dostępny na GitHubie.",
|
||||
"FAQ_Q7": "Czy mogę naprawić ciche dialogi i głośne eksplozje podczas oglądania filmów w przeglądarce?",
|
||||
"FAQ_A7": "Tak. KoalaSync 2.2.0 zawiera wbudowany kompresor audio, który zmniejsza różnicę między cichymi a głośnymi częściami filmu. Działa lokalnie przez Web Audio API — żadne dane audio nie opuszczają twojego komputera. Otwórz popup, utwórz pokój, przejdź do Ustawienia → Audio Processing, włącz kompresor i wybierz preset. Działa na Netflix, Prime Video, Disney+, YouTube i każdej stronie z wideo HTML5.",
|
||||
"HOWTO_TITLE": "Jak synchronizować wideo ze znajomymi za pomocą KoalaSync",
|
||||
"HOWTO_DESC": "Oglądaj dowolne wideo w idealnej synchronizacji ze znajomymi na YouTube, Netflix, Twitch i innych. Bez rejestracji, bez śledzenia.",
|
||||
"HOWTO_STEP_1_NAME": "Zainstaluj rozszerzenie przeglądarki",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "WYCZYŚĆ",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync nie jest powiązany, wspierany ani stowarzyszony z Netflix, Disney+, Amazon, YouTube, Twitch ani żadną inną platformą streamingową. Wszystkie znaki towarowe są własnością ich odpowiednich właścicieli.",
|
||||
"FOOTER_SUPPORT": "Postaw kawę na Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Assista à Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5 em perfeita sincronia com amigos. Extensão de navegador de código aberto e focada em privacidade para Chrome e Firefox.",
|
||||
"NAV_FEATURES": "Recursos",
|
||||
"NAV_HOW_IT_WORKS": "Como funciona",
|
||||
"HERO_TITLE": "Assistam juntos.<br>Sincronia perfeita.",
|
||||
"HERO_TITLE": "Assistam juntos.<span class='hero-line2'>Sincronia perfeita.</span>",
|
||||
"HERO_SUBTITLE": "Sua noite de cinema à distância sem atrasos. Sem registro, sem coleta de dados. Apenas compartilhe o link e assistam juntos.",
|
||||
"HERO_MASCOT_ALT": "Um lindo coala em pé olhando para baixo em direção aos botões de download",
|
||||
"ADD_TO_CHROME": "Adicionar ao Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Sem contas / Privacidade absoluta",
|
||||
"FEATURE_3_DESC": "Sem registro, sem rastreamento, sem armazenamento de dados. O servidor roda inteiramente em memória RAM efêmera e limpa sua sala assim que você sai.",
|
||||
"FEATURE_4_TITLE": "Suporte universal a HTML5",
|
||||
"FEATURE_4_DESC": "Funciona no YouTube, Twitch, Netflix, Jellyfin, Emby e em quase qualquer página web padrão com um elemento de vídeo HTML5. Também é compatível com servidores próprios.",
|
||||
"FEATURE_4_DESC": "Funciona no YouTube, Twitch, Netflix, Jellyfin, Emby e em quase qualquer página web padrão com um elemento de vídeo HTML5. Também é compatível com servidores próprios. Nenhum dado de vídeo é enviado aos nossos servidores — apenas metadados de sincronização.",
|
||||
"FEATURE_5_TITLE": "Auto-hospedável e pronto para Docker",
|
||||
"FEATURE_5_DESC": "Nossos servidores oficiais são rápidos e gratuitos, mas você pode ter controle total se desejar. Inicie seu próprio servidor de retransmissão privado em segundos via Docker.",
|
||||
"FEATURE_6_TITLE": "Convites instantâneos / Entrada em 1 clique",
|
||||
"FEATURE_6_DESC": "Sem troca de endereços IP ou senhas. Compartilhe um link de convite gerado para que seus amigos entrem automaticamente na sua sala com um único clique.",
|
||||
"FEATURE_7_TITLE": "Nivelamento de volume / Compressor de áudio",
|
||||
"FEATURE_7_DESC": "Chega de viver ajustando o volume. O compressor de áudio integrado realça diálogos baixos e controla explosões altas. Funciona na Netflix, Prime Video, Disney+, YouTube e qualquer vídeo HTML5. Completamente local — nenhum dado de áudio sai do seu computador.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"COMP_TITLE": "KoalaSync vs Teleparty",
|
||||
"COMP_SUBTITLE": "Veja por que o código aberto, sem anúncios e com privacidade em primeiro lugar é a melhor escolha para assistirem juntos.",
|
||||
"COMP_COL_FEATURE": "Recurso",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Idiomas de interface disponíveis e suporte a tradução.",
|
||||
"COMP_FEAT_6_KOALA": "13 idiomas (e crescendo)",
|
||||
"COMP_FEAT_6_TELE": "Apenas inglês",
|
||||
"COMP_FEAT_7_NAME": "Compressor de Áudio / Nivelamento de Volume",
|
||||
"COMP_FEAT_7_DESC": "Normalização de áudio integrada para equilibrar diálogos baixos e efeitos altos",
|
||||
"COMP_FEAT_7_KOALA": "Sim (4 predefinições, controle dry/wet)",
|
||||
"COMP_FEAT_7_TELE": "Não",
|
||||
"COMP_FOOTNOTE_1": "Estado da comparação: maio de 2026.",
|
||||
"COMP_FOOTNOTE_2": "Preços oficiais do Teleparty Premium e detalhes das redes suportadas:",
|
||||
"COMP_FOOTNOTE_3": "Políticas de privacidade do Teleparty e coleta de dados de rastreamento:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Sim, cada participante precisa do KoalaSync — mas sem cadastro ou criação de conta. Basta clicar no link de convite, instalar a extensão se necessário e você entra na sala automaticamente.",
|
||||
"FAQ_Q6": "Meus dados estão seguros? E se eu não confiar no servidor oficial?",
|
||||
"FAQ_A6": "Totalmente seguro. O servidor relay do KoalaSync opera apenas em RAM — todos os dados da sessão são excluídos ao sair da sala. Sem banco de dados, sem registros, sem análises, sem contas. Se preferir controle total: hospede seu próprio servidor relay privado via Docker em minutos. O código fonte é totalmente aberto e auditável no GitHub.",
|
||||
"FAQ_Q7": "Posso corrigir diálogos baixos e explosões altas ao assistir vídeos no navegador?",
|
||||
"FAQ_A7": "Sim. O KoalaSync 2.2.0 inclui um compressor de áudio integrado que reduz a diferença entre partes silenciosas e barulhentas do seu vídeo. Funciona localmente via Web Audio API — nenhum dado de áudio sai do seu computador. Abra o popup, crie uma sala, vá em Configurações → Audio Processing, ative o compressor e escolha um preset. Funciona na Netflix, Prime Video, Disney+, YouTube e qualquer site de vídeo HTML5.",
|
||||
"HOWTO_TITLE": "Como sincronizar vídeos com amigos usando o KoalaSync",
|
||||
"HOWTO_DESC": "Assista vídeos em perfeita sincronia com amigos no YouTube, Netflix, Twitch e mais. Sem cadastro, sem rastreamento.",
|
||||
"HOWTO_STEP_1_NAME": "Instalar a extensão",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "CLEAR",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync não é afiliado, endossado ou associado à Netflix, Disney+, Amazon, YouTube, Twitch ou qualquer outra plataforma de streaming. Todas as marcas registradas são propriedade de seus respectivos titulares.",
|
||||
"FOOTER_SUPPORT": "Me pague um café no Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"LANG_CODE": "pt",
|
||||
"HTML_CLASS": "lang-pt",
|
||||
"CANONICAL_PATH": "pt/",
|
||||
"LANG_TOGGLE_URL": "",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "pt_PT",
|
||||
"META_TITLE": "KoalaSync | Sincronize Netflix, YouTube e qualquer vídeo com os amigos",
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Veja Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5 em sincronia perfeita com os amigos. Extensão de navegador de código aberto e focada na privacidade para Chrome e Firefox.",
|
||||
"NAV_FEATURES": "Funcionalidades",
|
||||
"NAV_HOW_IT_WORKS": "Como Funciona",
|
||||
"HERO_TITLE": "Vejam Juntos.<br>Sincronizem Perfeitamente.",
|
||||
"HERO_TITLE": "Vejam Juntos.<span class='hero-line2'>Sincronizem Perfeitamente.</span>",
|
||||
"HERO_SUBTITLE": "A sua noite de cinema à distância e sem atrasos. Sem registo, sem recolha de dados. Partilhe apenas um link e vejam juntos.",
|
||||
"HERO_MASCOT_ALT": "Um coala fofo em pé e a olhar para baixo para os botões de download",
|
||||
"ADD_TO_CHROME": "Adicionar ao Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Zero Contas / Privacidade Pura",
|
||||
"FEATURE_3_DESC": "Sem registo, sem rastreio, sem pegadas de persistência. O servidor corre inteiramente em RAM e purga a sala quando você sai.",
|
||||
"FEATURE_4_TITLE": "Suporte HTML5 Universal",
|
||||
"FEATURE_4_DESC": "Funciona no YouTube, Twitch, Netflix, Jellyfin, Emby e em quase todas as páginas web padrão que contenham um elemento de vídeo HTML5. Compatível também com servidores personalizados.",
|
||||
"FEATURE_4_DESC": "Funciona no YouTube, Twitch, Netflix, Jellyfin, Emby e em quase todas as páginas web padrão que contenham um elemento de vídeo HTML5. Compatível também com servidores personalizados. Nenhum dado de vídeo é enviado aos nossos servidores — apenas metadados de sincronização.",
|
||||
"FEATURE_5_TITLE": "Auto-Hospedável e Pronto para Docker",
|
||||
"FEATURE_5_DESC": "Os nossos servidores oficiais são gratuitos e rápidos, mas pode assumir o controlo total se quiser. Lance o seu próprio servidor de retransmissão privado em segundos via Docker.",
|
||||
"FEATURE_6_TITLE": "Convites Instantâneos / Entrada com 1 Clique",
|
||||
"FEATURE_6_DESC": "Nenhum endereço IP ou palavra-passe para trocar. Partilhe um link de convite gerado com os seus amigos para que possam entrar na sala automaticamente com um único clique.",
|
||||
"FEATURE_7_TITLE": "Nivelamento de Volume / Compressor de Áudio",
|
||||
"FEATURE_7_DESC": "Nunca mais esteja sempre a ajustar o volume. O compressor de áudio integrado reforça diálogos suaves e doma explosões altas. Funciona na Netflix, Prime Video, Disney+, YouTube e qualquer vídeo HTML5. Totalmente local — nenhum dado de áudio sai do seu computador.",
|
||||
"FEATURE_8_TITLE": "Open Source (MIT License)",
|
||||
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
|
||||
"COMP_TITLE": "KoalaSync vs. Teleparty",
|
||||
"COMP_SUBTITLE": "Descubra porquê o código aberto, sem anúncios e focado na privacidade é a forma superior de ver vídeos juntos.",
|
||||
"COMP_COL_FEATURE": "Funcionalidade",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Idiomas de interface disponíveis e suporte de tradução.",
|
||||
"COMP_FEAT_6_KOALA": "13 Idiomas (e a crescer)",
|
||||
"COMP_FEAT_6_TELE": "Apenas Inglês",
|
||||
"COMP_FEAT_7_NAME": "Compressor de Áudio / Nivelamento de Volume",
|
||||
"COMP_FEAT_7_DESC": "Normalização de áudio integrada para equilibrar diálogos baixos e efeitos altos",
|
||||
"COMP_FEAT_7_KOALA": "Sim (4 predefinições, controlo dry/wet)",
|
||||
"COMP_FEAT_7_TELE": "Não",
|
||||
"COMP_FOOTNOTE_1": "Estado da comparação: Maio de 2026.",
|
||||
"COMP_FOOTNOTE_2": "Preços oficiais do Teleparty Premium e detalhes das redes suportadas:",
|
||||
"COMP_FOOTNOTE_3": "Políticas de privacidade do Teleparty e recolha de dados de rastreio:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Sim, cada participante precisa de ter o KoalaSync instalado — mas não há registo ou criação de conta. Basta clicar no link de convite, instalar a extensão se ainda não o fez, e entrará na sala automaticamente em segundos.",
|
||||
"FAQ_Q6": "Os meus dados estão seguros? E se eu não confiar no servidor oficial?",
|
||||
"FAQ_A6": "Totalmente seguro. O servidor de retransmissão do KoalaSync corre apenas em RAM — todos os dados da sessão são permanentemente eliminados no momento em que sai da sala. Sem base de dados, sem registo, sem análises, sem contas. E se preferir o controlo total: pode auto-hospedar o seu próprio servidor de retransmissão privado via Docker em minutos. Toda a base de código é aberta e auditável no GitHub.",
|
||||
"FAQ_Q7": "Posso corrigir diálogos suaves e explosões altas ao ver vídeos no navegador?",
|
||||
"FAQ_A7": "Sim. O KoalaSync 2.2.0 inclui um compressor de áudio integrado que reduz a diferença entre as partes silenciosas e barulhentas do seu vídeo. Funciona localmente através da Web Audio API — nenhum dado de áudio sai do seu computador. Abra o popup, crie uma sala, vá a Definições → Audio Processing, ative o compressor e escolha um preset. Funciona na Netflix, Prime Video, Disney+, YouTube e qualquer site de vídeo HTML5.",
|
||||
"HOWTO_TITLE": "Como sincronizar vídeos com amigos usando o KoalaSync",
|
||||
"HOWTO_DESC": "Veja qualquer vídeo em sincronia perfeita com os amigos no YouTube, Netflix, Twitch e mais. Sem registo, sem rastreio.",
|
||||
"HOWTO_STEP_1_NAME": "Instale a extensão do navegador",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "LIMPAR",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync não é afiliado, endossado ou associado à Netflix, Disney+, Amazon, YouTube, Twitch ou qualquer outra plataforma de streaming. Todas as marcas registadas são propriedade dos seus respetivos titulares.",
|
||||
"FOOTER_SUPPORT": "Oferece um café no Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Смотрите Netflix, Emby, Jellyfin, YouTube, Twitch и почти любое HTML5-видео в идеальной синхронизации с друзьями. Открытое и конфиденциальное расширение для Chrome и Firefox.",
|
||||
"NAV_FEATURES": "Функции",
|
||||
"NAV_HOW_IT_WORKS": "Как это работает",
|
||||
"HERO_TITLE": "Смотрите вместе.<br>Синхронно на 100%.",
|
||||
"HERO_TITLE": "Смотрите вместе.<span class='hero-line2'>Синхронно на 100%.</span>",
|
||||
"HERO_SUBTITLE": "Ваш киновечер на расстоянии без задержек. Без регистрации и сбора данных. Просто поделитесь ссылкой и смотрите вместе.",
|
||||
"HERO_MASCOT_ALT": "Милый коала стоит и смотрит вниз на кнопки загрузки",
|
||||
"ADD_TO_CHROME": "Установить в Chrome",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Без аккаунтов и хранения данных",
|
||||
"FEATURE_3_DESC": "Без регистрации, без отслеживания, без следов на диске. Сервер работает исключительно во временной RAM и полностью стирает вашу комнату при выходе.",
|
||||
"FEATURE_4_TITLE": "Универсальная поддержка HTML5",
|
||||
"FEATURE_4_DESC": "Работает на YouTube, Twitch, Netflix, Jellyfin, Emby и почти на любой веб-странице со стандартным HTML5-видеотегом. Подходит для локальных медиатек.",
|
||||
"FEATURE_4_DESC": "Работает на YouTube, Twitch, Netflix, Jellyfin, Emby и почти на любой веб-странице со стандартным HTML5-видеотегом. Подходит для локальных медиатек. Никакие видеоданные никогда не отправляются на наши серверы — только метаданные синхронизации.",
|
||||
"FEATURE_5_TITLE": "Self-Hosted и готов к Docker",
|
||||
"FEATURE_5_DESC": "Наши официальные серверы бесплатны и быстры, но вы можете получить полный контроль. Запустите свой собственный приватный ретранслятор в Docker за секунды.",
|
||||
"FEATURE_6_TITLE": "Быстрые приглашения в 1 клик",
|
||||
"FEATURE_6_DESC": "Не нужно обмениваться IP-адресами или паролями. Отправьте сгенерированную ссылку друзьям, чтобы они автоматически вошли в комнату в один клик.",
|
||||
"FEATURE_7_TITLE": "Выравнивание громкости / Аудио компрессор",
|
||||
"FEATURE_7_DESC": "Больше никакой постоянной регулировки громкости. Встроенный аудио компрессор усиливает тихие диалоги и приглушает громкие взрывы. Работает на Netflix, Prime Video, Disney+, YouTube и любом HTML5-видео. Полностью локально — никакие аудиоданные не покидают ваш компьютер.",
|
||||
"FEATURE_8_TITLE": "Открытый исходный код (Лицензия MIT)",
|
||||
"FEATURE_8_DESC": "Весь исходный код публично доступен для проверки на GitHub. Никаких «черных ящиков», никаких проприетарных бинарных файлов (blobs) — 100% прозрачность от relay-сервера до расширения браузера.",
|
||||
"COMP_TITLE": "KoalaSync против Teleparty",
|
||||
"COMP_SUBTITLE": "Узнайте, почему открытый исходный код, отсутствие рекламы и конфиденциальность делают нас лучшим выбором для совместного просмотра.",
|
||||
"COMP_COL_FEATURE": "Функция",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Доступные языки интерфейса и поддержка перевода.",
|
||||
"COMP_FEAT_6_KOALA": "13 языков (и расширяется)",
|
||||
"COMP_FEAT_6_TELE": "Только английский",
|
||||
"COMP_FEAT_7_NAME": "Аудиокомпрессор / Выравнивание Громкости",
|
||||
"COMP_FEAT_7_DESC": "Встроенная нормализация звука для баланса тихих диалогов и громких эффектов",
|
||||
"COMP_FEAT_7_KOALA": "Да (4 пресета, управление dry/wet)",
|
||||
"COMP_FEAT_7_TELE": "Нет",
|
||||
"COMP_FOOTNOTE_1": "Состояние сравнения: май 2026.",
|
||||
"COMP_FOOTNOTE_2": "Официальные цены Teleparty Premium и поддерживаемые сети:",
|
||||
"COMP_FOOTNOTE_3": "Политика конфиденциальности Teleparty и сбор трекеров:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Да, каждому участнику нужен KoalaSync — но без регистрации и создания учётной записи. Просто перейдите по ссылке-приглашению, установите расширение (если ещё нет) и вы автоматически в комнате.",
|
||||
"FAQ_Q6": "Мои данные в безопасности? Что, если я не доверяю официальному серверу?",
|
||||
"FAQ_A6": "Полностью безопасно. Relay-сервер KoalaSync работает только в ОЗУ — все данные сессии удаляются при выходе из комнаты. Никаких баз данных, логов, аналитики и учётных записей. Если хотите полный контроль: разверните свой приватный relay-сервер через Docker за пару минут. Исходный код полностью открыт и доступен для аудита на GitHub.",
|
||||
"FAQ_Q7": "Можно ли исправить тихие диалоги и громкие взрывы при просмотре видео в браузере?",
|
||||
"FAQ_A7": "Да. KoalaSync 2.2.0 включает встроенный аудио компрессор, который уменьшает разницу между тихими и громкими частями видео. Он работает локально через Web Audio API — аудиоданные никогда не покидают ваш компьютер. Откройте попап, создайте комнату, перейдите в Настройки → Audio Processing, включите компрессор и выберите пресет. Работает на Netflix, Prime Video, Disney+, YouTube и любом сайте с HTML5-видео.",
|
||||
"HOWTO_TITLE": "Как синхронизировать видео с друзьями с помощью KoalaSync",
|
||||
"HOWTO_DESC": "Смотрите видео в идеальной синхронизации с друзьями на YouTube, Netflix, Twitch и других платформах. Без регистрации, без отслеживания.",
|
||||
"HOWTO_STEP_1_NAME": "Установите расширение",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "CLEAR",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync не связан, не спонсируется и не аффилирован с Netflix, Disney+, Amazon, YouTube, Twitch или любой другой стриминговой платформой. Все товарные знаки являются собственностью их соответствующих владельцев.",
|
||||
"FOOTER_SUPPORT": "Угости кофе на Ko-Fi"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"LANG_CODE": "tr",
|
||||
"HTML_CLASS": "lang-tr",
|
||||
"CANONICAL_PATH": "tr/",
|
||||
"LANG_TOGGLE_URL": "",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN",
|
||||
"OG_LOCALE": "tr_TR",
|
||||
"META_TITLE": "KoalaSync | Netflix, YouTube ve Herhangi Bir Videoyu Arkadaşlarınızla Eşitleyin",
|
||||
@@ -13,7 +13,7 @@
|
||||
"TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch ve neredeyse tüm HTML5 videolarını arkadaşlarınızla mükemmel bir senkronizasyonla izleyin. Chrome ve Firefox için gizlilik öncelikli, açık kaynaklı tarayıcı eklentisi.",
|
||||
"NAV_FEATURES": "Özellikler",
|
||||
"NAV_HOW_IT_WORKS": "Nasıl Çalışır",
|
||||
"HERO_TITLE": "Birlikte İzleyin.<br>Mükemmel Eşitleyin.",
|
||||
"HERO_TITLE": "Birlikte İzleyin.<span class='hero-line2'>Mükemmel Eşitleyin.</span>",
|
||||
"HERO_SUBTITLE": "Gecikmesiz uzaktan film geceniz. Kayıt yok, veri toplama yok. Sadece bir bağlantı paylaşın ve birlikte izleyin.",
|
||||
"HERO_MASCOT_ALT": "İndirme düğmelerine bakan sevimli bir koala",
|
||||
"ADD_TO_CHROME": "Chrome'a Ekle",
|
||||
@@ -41,11 +41,15 @@
|
||||
"FEATURE_3_TITLE": "Sıfır Hesap / Saf Gizlilik",
|
||||
"FEATURE_3_DESC": "Kayıt yok, takip yok, kalıcı iz yok. Sunucu tamamen geçici RAM üzerinde çalışır ve ayrıldığınızda odanızı temizler.",
|
||||
"FEATURE_4_TITLE": "Evrensel HTML5 Desteği",
|
||||
"FEATURE_4_DESC": "YouTube, Twitch, Netflix, Jellyfin, Emby ve HTML5 video öğesi içeren neredeyse tüm standart web sayfalarında çalışır. Ayrıca özel barındırılan kurulumlarla da uyumludur.",
|
||||
"FEATURE_4_DESC": "YouTube, Twitch, Netflix, Jellyfin, Emby ve HTML5 video öğesi içeren neredeyse tüm standart web sayfalarında çalışır. Ayrıca özel barındırılan kurulumlarla da uyumludur. Sunucularımıza hiçbir video verisi asla gönderilmez — yalnızca senkronizasyon meta verileri gönderilir.",
|
||||
"FEATURE_5_TITLE": "Kendi Kendine Barındırılabilir ve Docker Uyumlu",
|
||||
"FEATURE_5_DESC": "Resmi sunucularımız ücretsiz ve hızlıdır, ancak isterseniz tüm kontrolü elinize alabilirsiniz. Docker aracılığıyla saniyeler içinde kendi özel aktarım sunucunuzu başlatın.",
|
||||
"FEATURE_6_TITLE": "Anında Davetler / Tek Tıkla Katılım",
|
||||
"FEATURE_6_DESC": "Takas edilecek IP adresi veya şifre yok. Arkadaşlarınızın tek bir tıklamayla odanıza otomatik olarak katılması için oluşturulan bir davet bağlantısını paylaşın.",
|
||||
"FEATURE_7_TITLE": "Ses Seviyesi Dengeleme / Ses Sıkıştırıcı",
|
||||
"FEATURE_7_DESC": "Ses düğmesiyle sürekli oynamak yok. Dahili ses sıkıştırıcı, sessiz diyalogları güçlendirir ve yüksek patlamaları yumuşatır. Netflix, Prime Video, Disney+, YouTube ve herhangi bir HTML5 videosunda çalışır. Tamamen yerel — hiçbir ses verisi bilgisayarınızı terk etmez.",
|
||||
"FEATURE_8_TITLE": "Açık Kaynak (MIT Lisansı)",
|
||||
"FEATURE_8_DESC": "Tüm kod tabanı GitHub'da herkese açık olarak incelenebilir. Kapalı kutular yok, tescilli blob'lar yok — aktarma sunucusundan (relay server) tarayıcı eklentisine kadar %100 şeffaflık.",
|
||||
"COMP_TITLE": "KoalaSync vs. Teleparty",
|
||||
"COMP_SUBTITLE": "Açık kaynaklı, reklamsız ve önce gizlilik diyen yöntemin neden birlikte izlemenin en iyi yolu olduğunu görün.",
|
||||
"COMP_COL_FEATURE": "Özellik",
|
||||
@@ -73,6 +77,10 @@
|
||||
"COMP_FEAT_6_DESC": "Mevcut arayüz dilleri ve çeviri desteği.",
|
||||
"COMP_FEAT_6_KOALA": "13 Dil (ve artıyor)",
|
||||
"COMP_FEAT_6_TELE": "Sadece İngilizce",
|
||||
"COMP_FEAT_7_NAME": "Ses Kompresörü / Ses Seviyesi Dengeleme",
|
||||
"COMP_FEAT_7_DESC": "Sessiz diyaloglar ve yüksek efektler arasında denge sağlayan yerleşik ses normalizasyonu",
|
||||
"COMP_FEAT_7_KOALA": "Evet (4 ön ayar, dry/wet kontrolü)",
|
||||
"COMP_FEAT_7_TELE": "Hayır",
|
||||
"COMP_FOOTNOTE_1": "Karşılaştırma durumu: Mayıs 2026.",
|
||||
"COMP_FOOTNOTE_2": "Resmi Teleparty Premium fiyatlandırması ve desteklenen ağ ayrıntıları:",
|
||||
"COMP_FOOTNOTE_3": "Teleparty gizlilik politikaları ve takip verisi toplama:",
|
||||
@@ -118,6 +126,8 @@
|
||||
"FAQ_A5": "Evet, her katılımcının KoalaSync'i kurması gerekir — ancak kayıt veya hesap oluşturma yoktur. Sadece davet bağlantısına tıklayın, henüz yapmadıysanız eklentiyi kurun, saniyeler içinde odaya otomatik olarak katılacaksınız.",
|
||||
"FAQ_Q6": "Verilerim güvende mi? Ya resmi sunucuya güvenmiyorsam?",
|
||||
"FAQ_A6": "Tamamen güvende. KoalaSync'in aktarım sunucusu yalnızca RAM üzerindedir — odadan ayrıldığınız andan itibaren tüm oturum verileri kalıcı olarak silinir. Veritabanı yok, günlük kaydı yok, analitik yok, hesap yok. Ve tam kontrolü tercih ediyorsanız: Docker aracılığıyla kendi özel aktarım sunucunuzu dakikalar içinde barındırabilirsiniz. Tüm kod tabanı açık kaynaklıdır ve GitHub'da denetlenebilir.",
|
||||
"FAQ_Q7": "Tarayıcıda video izlerken sessiz diyalogları ve yüksek patlamaları düzeltebilir miyim?",
|
||||
"FAQ_A7": "Evet. KoalaSync 2.2.0, videonuzun sessiz ve yüksek kısımları arasındaki farkı azaltan yerleşik bir ses sıkıştırıcı içerir. Web Audio API aracılığıyla yerel olarak çalışır — hiçbir ses verisi bilgisayarınızdan çıkmaz. Popup'ı açın, bir oda oluşturun, Ayarlar → Audio Processing'e gidin, sıkıştırıcıyı açın ve bir ön ayar seçin. Netflix, Prime Video, Disney+, YouTube ve herhangi bir HTML5 video sitesinde çalışır.",
|
||||
"HOWTO_TITLE": "KoalaSync kullanarak arkadaşlarınızla videoları senkronize etme",
|
||||
"HOWTO_DESC": "YouTube, Netflix, Twitch ve daha fazlasında arkadaşlarınızla mükemmel uyum içinde herhangi bir videoyu izleyin. Kayıt yok, takip yok.",
|
||||
"HOWTO_STEP_1_NAME": "Tarayıcı eklentisini kurun",
|
||||
@@ -164,5 +174,6 @@
|
||||
"MOCK_32": "TEMİZLE",
|
||||
"FOOTER_LEGAL_LINK": "imprint",
|
||||
"FOOTER_PRIVACY_LINK": "privacy",
|
||||
"FOOTER_DISCLAIMER": "KoalaSync; Netflix, Disney+, Amazon, YouTube, Twitch veya başka herhangi bir yayın platformuna bağlı değildir, bunlar tarafından desteklenmemektedir ve bunlarla ilişkili değildir. Tüm ticari markalar ilgili sahiplerinin mülkiyetindedir.",
|
||||
"FOOTER_SUPPORT": "Ko-Fi'de bana kahve ısmarla"
|
||||
}
|
||||
|
||||
@@ -3,31 +3,31 @@
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/imprint</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/privacy</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/impressum</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -47,7 +47,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -67,7 +67,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/fr/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -87,7 +87,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/es/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -107,7 +107,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pt-BR/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -127,7 +127,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ru/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -147,7 +147,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/it/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -167,7 +167,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pl/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -187,7 +187,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/tr/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -207,7 +207,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/nl/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -227,7 +227,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ja/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -247,7 +247,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ko/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -267,7 +267,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pt/</loc>
|
||||
<lastmod>2026-06-04</lastmod>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
|
||||
@@ -233,11 +233,17 @@ nav {
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.1em;
|
||||
background: linear-gradient(to bottom, #fff 40%, #6366f1);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero-text h1 .hero-line2 {
|
||||
display: block;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.hero-text p, .hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
@@ -2076,6 +2082,8 @@ html:not(.lang-de) [lang="de"] {
|
||||
.feature-card:nth-child(4) { --card-accent: rgba(56, 189, 248, 0.06); }
|
||||
.feature-card:nth-child(5) { --card-accent: rgba(245, 158, 11, 0.06); }
|
||||
.feature-card:nth-child(6) { --card-accent: rgba(236, 72, 153, 0.06); }
|
||||
.feature-card:nth-child(7) { --card-accent: rgba(249, 115, 22, 0.06); }
|
||||
.feature-card:nth-child(8) { --card-accent: rgba(16, 185, 129, 0.06); }
|
||||
|
||||
.feature-card {
|
||||
background: linear-gradient(135deg, var(--card-accent, transparent), var(--card));
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/",
|
||||
"inLanguage": ["en", "de", "fr", "es", "pt-BR", "ru"]
|
||||
"inLanguage": ["en", "de", "fr", "es", "pt-BR", "ru", "it", "pl", "tr", "nl", "ja", "ko", "pt"]
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json" id="schema-software">
|
||||
@@ -102,7 +102,7 @@
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"description": "Watch Netflix, YouTube, Twitch, Jellyfin, Emby and almost any HTML5 video in perfect sync with friends. Open source, privacy-first, free.",
|
||||
"softwareVersion": "1.9.3",
|
||||
"softwareVersion": "2.3.0",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||
@@ -174,6 +174,11 @@
|
||||
"@type": "Question",
|
||||
"name": "{{FAQ_Q6}}",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "{{FAQ_A6}}" }
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "{{FAQ_Q7}}",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "{{FAQ_A7}}" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -252,8 +257,8 @@
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/blob/main/docs/CHANGELOG.md" target="_blank" rel="noopener" class="version-badge" data-reveal style="text-decoration: none; display: inline-block; transition: transform 0.2s, box-shadow 0.2s;" title="View Changelog">
|
||||
<span class="version-text-en" lang="en">v2.1.0 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v2.1.0 JETZT VERFÜGBAR</span>
|
||||
<span class="version-text-en" lang="en">v2.2.1 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v2.2.1 JETZT VERFÜGBAR</span>
|
||||
</a>
|
||||
<h1 data-reveal>{{HERO_TITLE}}</h1>
|
||||
<p class="hero-subtitle" data-reveal>{{HERO_SUBTITLE}}</p>
|
||||
@@ -711,6 +716,28 @@
|
||||
</h3>
|
||||
<p>{{FEATURE_5_DESC}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Card 7: Open Source / MIT License (Bento Standard) -->
|
||||
<div class="feature-card" data-reveal>
|
||||
<h3>
|
||||
<span class="feature-icon-svg">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="bento-icon"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
</span>
|
||||
<span>{{FEATURE_8_TITLE}}</span>
|
||||
</h3>
|
||||
<p>{{FEATURE_8_DESC}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Card 8: Volume Leveling / Audio Compressor (Bento Large) -->
|
||||
<div class="feature-card bento-large" data-reveal>
|
||||
<h3>
|
||||
<span class="feature-icon-svg">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="bento-icon"><path d="M11 5 6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||||
</span>
|
||||
<span>{{FEATURE_7_TITLE}}</span>
|
||||
</h3>
|
||||
<p>{{FEATURE_7_DESC}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -801,6 +828,16 @@
|
||||
✘ <span>{{COMP_FEAT_6_TELE}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat-name">
|
||||
<strong><span>{{COMP_FEAT_7_NAME}}</span></strong>
|
||||
<span class="feat-desc"><span>{{COMP_FEAT_7_DESC}}</span></span>
|
||||
</td>
|
||||
<td class="check highlight">✔ <span>{{COMP_FEAT_7_KOALA}}</span></td>
|
||||
<td class="cross">
|
||||
✘ <span>{{COMP_FEAT_7_TELE}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footnotes">
|
||||
@@ -1165,6 +1202,10 @@
|
||||
<summary><span>{{FAQ_Q6}}</span></summary>
|
||||
<p><span>{{FAQ_A6}}</span></p>
|
||||
</details>
|
||||
<details class="faq-item" data-reveal>
|
||||
<summary><span>{{FAQ_Q7}}</span></summary>
|
||||
<p><span>{{FAQ_A7}}</span></p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1205,6 +1246,7 @@
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">{{FOOTER_RAM}}</p>
|
||||
<p style="font-size: 0.72rem; margin-top: 1.2rem; color: var(--text-muted); opacity: 0.65; max-width: 650px; margin-left: auto; margin-right: auto; line-height: 1.5;">{{FOOTER_DISCLAIMER}}</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}" style="color: var(--text-muted); text-decoration: none;"><span>{{FOOTER_LEGAL}}</span></a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}" style="color: var(--text-muted); text-decoration: none;"><span>{{FOOTER_PRIVACY}}</span></a>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.2.0",
|
||||
"date": "2026-06-08T19:31:38Z"
|
||||
"version": "2.3.0",
|
||||
"date": "2026-06-14T03:17:45Z"
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Auto-Generated Output — Do Not Edit
|
||||
|
||||
This directory is **fully auto-generated** by `node website/build.js`.
|
||||
|
||||
- Edit source files in `../` (`template.html`, `style.css`, `app.js`, `lang-init.js`, `locales/*.json`)
|
||||
- Run `node website/build.js` to regenerate
|
||||
|
||||
Never edit files here directly — changes will be lost on the next build.
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB |