Compare commits

..

96 Commits

Author SHA1 Message Date
Timo ed80856803 fix: restore missing Create Room onboarding step and target h1 for welcome step 2026-06-14 05:10:57 +02:00
Timo 102031e0d2 style: swap restart onboarding and regenerate peer ID buttons in settings layout 2026-06-14 05:03:27 +02:00
Timo 503c7d6dc4 chore: update extension uninstall URL and fix missing onboarding restart translations 2026-06-14 05:02:31 +02:00
KoalaDev 77793c8c6e feat(extension): setup cross-browser uninstall url architecture 2026-06-13 21:01:31 +02:00
KoalaDev 4fbf309e5a fix(extension): resolve critical race conditions and infinite seek loop
- Fix infinite seek loop by transitioning to exact expectedSeekTime tracking
- Fix zombie connections by calling forceDisconnect on ping timeouts
- Fix popup Join/Leave race conditions using isProcessingConnection lock
- Fix cross-room join bugs by properly disconnecting old rooms and bypassing same-room joins
- Update onboarding UI to switch to Sync tab automatically
- Prepare CHANGELOG.md for v2.3.0 release
2026-06-13 10:50:46 +02:00
KoalaDev aa61a24351 Add ROADMAP.md with feature tracking and link in README 2026-06-13 06:35:56 +02:00
Timo 2ee5c83ee6 docs: add v2.2.5 unreleased changelog entries 2026-06-13 04:54:57 +02:00
KoalaDev ca1cfdb382 Merge pull request #7 from Shik3i/dependabot/npm_and_yarn/npm_and_yarn-53cbaf2a5b
chore(deps-dev): bump esbuild from 0.28.0 to 0.28.1 in the npm_and_yarn group across 1 directory
2026-06-13 04:53:43 +02:00
Timo ef7b1f2e5f fix: suppress video heartbeat PEER_STATUS when alone in room 2026-06-13 04:52:14 +02:00
Timo 10fdaa23fc fix: propagate audio settings from local storage and add compressor feedback 2026-06-13 04:48:25 +02:00
Timo 6ba5e1b10b Increase failedAuthAttempts eviction limit from 50k to 200k 2026-06-13 04:38:22 +02:00
dependabot[bot] 6e234fb8fd chore(deps-dev): bump esbuild
Bumps the npm_and_yarn group with 1 update in the / directory: [esbuild](https://github.com/evanw/esbuild).


Updates `esbuild` from 0.28.0 to 0.28.1
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

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

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

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

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

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

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

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+13 -4
View File
@@ -34,14 +34,14 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
## 3. Mandatory Reading
Before touching any code, you MUST read the following documents in order:
1. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
2. [extension/README.md](extension/README.md) Extension components, tab structure, and loading process.
3. [docs/SYNC_GUIDE.md](docs/SYNC_GUIDE.md) Protocol constants and synchronization requirements.
1. [ARCHITECTURE.md](ARCHITECTURE.md) Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
2. [extension/README.md](../extension/README.md) Extension components, tab structure, and loading process.
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) Protocol constants and synchronization requirements.
## 4. The "Vanilla JS Mirror" Pattern
To avoid boot-time race conditions in Manifest V3 without a bundler, the following architectural trade-off is enforced:
- **Synchronous Execution**: `content.js` MUST execute synchronously to catch early media events.
- **Automated Injection**: The build script (`node scripts/build-extension.js`) automatically injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` using marker-based replacement (see `scripts/README.md` for marker details).
- **Automated Injection**: The build script (`node scripts/build-extension.js`) automatically injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` using marker-based replacement (see `../scripts/README.md` for marker details).
- **Maintenance**: After modifying `shared/constants.js`, simply run the build script. No manual mirroring is required.
## 5. File Responsibility Map
@@ -100,6 +100,15 @@ The following features are critical and must not be removed or fundamentally alt
## 10. Common Workflows
## CRITICAL: Git & Release Rules
- **NEVER** push, commit, tag, or release without explicit user instruction.
- **NEVER** retag or force-push without explicit instruction.
- **NEVER** create tags for documentation-only or README changes.
- **NEVER** run `git push`, `git tag`, `git commit` unless the user says "push", "commit", or "tag".
- Only the user decides when to commit, push, tag, or release.
- Ask before any git write operation.
### ⚠️ Pre-Session Git Sync (MANDATORY)
Before starting any task, committing, or pushing, you **MUST** run `git pull --rebase` to ensure your local branch is up-to-date with `origin/main`. CI pipelines and other agents may push commits concurrently. Skipping this step will cause merge conflicts and rejected pushes.
+240
View File
@@ -0,0 +1,240 @@
# KoalaSync Changelog
All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.3.0] — Unreleased
### Added
- **Extension: New Interactive Onboarding Tour**: A fully redesigned, interactive step-by-step onboarding experience.
- **Extension: Auto-Switch to Sync Tab**: The UI now intelligently switches to the Sync tab when you join a room to guide video selection.
- **Extension: Uninstall URL Integration**: Prepared an uninstall URL setup that works natively across Chrome and Firefox, cleanly attaching browser context for analytics.
### Fixed
- **Extension: Infinite Seek Loop Prevention**: Replaced the fragile time-based seek suppression with an exact target-time verification mechanism, entirely eliminating infinite seek loops on slow buffers.
- **Extension: Zombie Connections Resolved**: Implemented a forced disconnect upon ping timeouts, ensuring the extension reliably auto-reconnects when the WebSocket hangs in a half-open state.
- **Extension: Room Switching Architecture**: Joining a new room while already connected now explicitly severs the old connection first, preventing state cross-contamination.
- **Extension: Join/Leave Race Conditions**: Added UI locks to prevent users from accidentally sending conflicting connection commands via rapid double-clicking.
- **Extension: Same-Room Invite Bypass**: Clicking an invite link for the room you are currently in no longer triggers a redundant reconnect, instead instantly confirming the join.
- **Extension: Audio settings now propagate immediately to video tabs**: Changes made in the audio options page are now instantly applied to the active video tab. Previously, settings saved to `chrome.storage.local` were not picked up by the background listener, which only watched `chrome.storage.sync`.
- **Extension: Audio compressor now logs enable/disable state and resume failures**: The compressor reports when it is activated or bypassed, and warns if the `AudioContext` cannot be resumed (e.g. browser autoplay policy requires a user gesture on the page first).
- **Extension: Video heartbeat no longer sent when alone in a room**: The full media metadata `PEER_STATUS` is now only emitted when other peers are present. The session keepalive (background heartbeat) continues to run unaffected, preventing the server reaper from disconnecting idle peers.
- **Server: Increased `failedAuthAttempts` eviction threshold from 50k to 200k**: Reduces frequency of expensive batch evictions under high auth-failure volumes, smoothing heap usage.
---
## [v2.2.4] — 2026-06-10
### Fixed
- **Extension: Error notifications now respect `browserNotifications` setting**: Server error events (e.g. "Server is restarting") no longer trigger a browser notification when the user has disabled notifications in the extension settings.
- **Server: Misleading reconnect message corrected**: The graceful shutdown message no longer tells users to manually reconnect — the extension handles this automatically.
---
## [v2.2.3] — 2026-06-10
### Added
- **Artifact Attestations (Supply Chain Security)**: All release artifacts (Docker images, extension ZIPs) are now published with signed [SLSA provenance attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) via `actions/attest@v4`. Anyone can verify that an artifact was built from this repository using `gh attestation verify`.
- **Admin health: `rateLimits.denied` counters**: New rolling counters track actual rate-limit denials (429 responses), separate from `rateLimits.trackedClients` which reports unique IPs in the tracking window.
- **Docker HEALTHCHECK**: Container health is now checked every 30s via `GET /health`.
- **`npm start` script**: Server can now be started with `npm start`.
### Fixed
- **Server: `activeLobby` no longer silently overwritten**: If a second peer sends `EPISODE_LOBBY` while a lobby is already active, the request is now ignored instead of destroying the first peer's lobby.
- **CORS log sanitization**: Rejected origin headers are sanitized (`\r\n` stripped) to prevent log injection.
- **Extension: pagehide resource leak**: `keepAlivePort`, `lobbyPollTimer`, heartbeats, and `MutationObserver` are now properly cleaned up when a tab is hidden or enters bfcache.
- **Extension: unhandled storage rejections**: `chrome.storage.session.set()` calls in the disconnect handler now have `.catch(() => {})`.
- **`'Pixel'` duplicate in name generator**: Second occurrence replaced with `'Nitro'` for better name diversity.
- **`'opposum'` typo**: Corrected to `'opossum'` in the emoji map and added `'Opossum'` to `USERNAME_NOUNS`.
- **Test reliability**: `test-server-routes.mjs` now sets `ADMIN_METRICS_TOKEN` before importing the server module, fixing standalone test execution.
- **`MAX_PEERS_PER_ROOM` konsistent**: `.env` auf 25 gesetzt (wie `.env.example`).
- **pt-BR.json duplicate removed**: Duplicate `FOOTER_DISCLAIMER` key removed from website locale.
### Changed
- **Admin health: `rateLimitEntries` renamed to `rateLimits.trackedClients`**: The field now accurately describes that it tracks unique clients in the rate-limit window, not denial counts. Update your json_exporter/Grafana config accordingly.
- **README restructured**: Sections reordered by progressive technical depth. New "Supply Chain Security" subsection under "For Developers & Self-Hosters" with verification commands.
---
## [v2.2.2] — 2026-06-09
### Added
- **Chrome Web Store i18n Support**: Added `default_locale: "en"` to manifest and created `_locales/*/messages.json` for all 13 supported languages. This unlocks the language selection dropdown in the Chrome Web Store dashboard, allowing translated store listings (title, description) per locale. The extension's own UI translations (`locales/*.json` + `i18n.js`) remain unchanged.
- **Locale test coverage**: Extended `scripts/test-locales.js` to validate all `_locales/*/messages.json` files (correct format, required keys, no duplicates) and verify `default_locale` is set in the manifest.
### Fixed
- **Copy Logs button alignment**: Removed stray `margin-top: 8px` inherited from `.secondary` class that pushed the button 8px down in the connection status row.
---
## [v2.2.1] — 2026-06-09
### Added
- **Server Ping Display**: Measures round-trip latency to the relay server via application-level ping/pong events. The extension sends `PING { t }` every 15 seconds; the server responds with `PONG { t }`. Round-trip time is calculated client-side and displayed in the Status tab, color-coded (<50ms green, 50150ms yellow, >150ms red). No ping value is shown when disconnected or if the server does not respond within 5 seconds.
- **Peer Ping Response (Future-Proof)**: The extension can now respond to incoming `PING { t, sender }` events from other peers by sending back `PONG { t, target: sender }`. The relay server forwards `PING` to the target peer and routes `PONG` back to the original sender. Both client and server validate that peers are in the same room before forwarding/routing. Peer-to-peer ping initiation will be activated in a future extension update without requiring a server restart.
---
## [v2.2.0] — 2026-06-08
### Added
- **Web Audio API Compressor**: Built-in audio dynamic range compression with four presets (Recommended, Dynamic Range, Vocal Enhancement, Smooth) and fully customizable sliders (threshold, ratio, knee, attack, release). Uses dry/wet crossfade (40ms linear ramp) to avoid clicks. Configured via the new Audio Options page accessible from the Settings tab.
- **Audio Options Page** (`audio-options.html`): Dedicated settings page with master toggle, compressor preset selector, real-time custom sliders, and equalizer placeholder. Dark theme matching the popup design.
- **Feature Hint System**: Generic `dismissedHints` array in sync storage for announcing new features. First hint highlights the Audio Options entry in Settings. Extensible for future features.
### Changed
- **Ko-Fi Support Links**: Static footer badges on the Settings and Status tabs linking to the developer's support page. README and website footer updated with Ko-Fi badge.
### Fixed
- **Portuguese (PT) locale**: Removed Italian contamination — "sincronizzazione" → "sincronização", "tempo reale" → "tempo real", "Link di Invito" → "Link de Convite", "Sair della Sala" → "Sair da Sala".
- **Korean locale**: Fixed broken character in `HOWTO_STEP_2_TEXT` (`클rip보드``클립보드`).
- **Website COMP_FEAT_6_KOALA**: Normalized from inconsistent "6 Languages" to "13 Languages" across all locale files (en, de, es, fr, pt-BR, ru).
- **Debug report showing wrong logs**: Fixed `logs.slice(-50)` and `history.slice(-20)` in the "Copy Debug Report" feature. Since `addLog()` and `addToHistory()` use `unshift` (inserting entries at index 0), the arrays are ordered newest-first. `slice(-N)` took the N **oldest** entries instead of the N **newest**. Changed to `slice(0, N).reverse()` to correctly include the most recent logs and display them chronologically.
---
## [v2.1.2] — 2026-06-06
### Fixed
- **Episode guard regex**: Fixed `isDifferentEpisode()` not detecting episode changes when the MediaSession title uses `Sxx:Exx` format (colon separator, as used by Jellyfin/Emby). The regex character class `[\s\-\.]` was replaced with `[^a-zA-Z0-9]` to match **any** non-alphanumeric separator between season and episode numbers, preventing play/pause/seek commands from a different episode leaking through and incorrectly manipulating a peer's playback.
- **Per-device storage isolation**: Migrated `username`, `roomId`, `password`, `serverUrl`, and `useCustomServer` from `chrome.storage.sync` (synced across Google account) to `chrome.storage.local` (per-device). This prevents the extension from automatically joining the same room with the same name on multiple devices. Existing user data is migrated silently on first run; all preferences (`filterNoise`, `autoSyncNextEpisode`, etc.) remain synced.
### Changed
- Added one-time migration fallback in `getSettings()` and popup `init()` to copy existing user settings from `storage.sync` to `storage.local` on first launch after the update.
---
## [v2.1.0] — 2026-06-04
### Added
- Added full translation support for 7 new languages to both the browser extension popup settings and landing website: Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`).
- Implemented robust, centralized browser system language detection mapping `pt-BR` to Brazilian Portuguese and other `pt` locales (like `pt-PT`) automatically to European Portuguese.
- Added flag emojis to language selector dropdowns in both the extension popup and landing/utility web pages for quicker visual identification.
- Added 181 translation keys parity validation suite checks for the new languages.
### Fixed & Hardened (Extension Audit)
- Guarded all website `localStorage` interactions to prevent initialization/join flow script failures on privacy-hardened or cookie-blocked browser configurations.
- Added robust validation null-guards to `chrome.runtime.onMessage` listeners across all extension scripts (`bridge.js`, `content.js`, `background.js`, `popup.js`) to reject unexpected runtime messages.
- Guarded CustomEvent payload destructuring in `bridge.js` to ensure stability when receiving third-party page events.
- Wrapped `video.currentTime` seeking adjustments during forced sync in content scripts with exception handling to absorb uninitialized video state DOMExceptions.
- Added payload validation guards on incoming Socket.IO events within the background script's event handlers to secure against malformed server updates.
- Prevented noisy browser console exceptions from context invalidation in target tabs by catching promise rejections on extension message dispatches.
### Performance
- Implemented in-memory language dictionary caching in the background script to completely avoid redundant extension package filesystem reads during translations.
---
## [v2.0.8] — 2026-06-03
### Fixed
- Fixed a bug where switching language inside the extension popup overwrote dynamic fields (such as active room ID, connection status, active server details, and video debug info) with default localized placeholder texts.
- Fixed a version reporting mismatch where the copied logs (debug reports) and connection handshake parameters incorrectly reported the hardcoded `1.9.0` version instead of the actual installed manifest version.
---
## [v2.0.7] — 2026-06-03
### Added
- Added a `DEBUG_LOGGING` environment variable to the relay server (defaulting to `"0"` / disabled) to prevent console spam from verbose connection (`CONN`), room activity (`ROOM`, `DEDUPE`), and `CORS` events under load. Critical logs like `SERVER`, `SECURITY`, `AUTH`, and `ERROR` remain enabled at all times.
---
## [v2.0.6] — 2026-06-03
### Performance & Security Hardening
- Optimized failed authentication attempts cache eviction algorithm to $O(1)$ by exploiting Javascript `Map` insertion-order properties. This completely removes the previous array copying and sorting bottleneck, neutralizing a potential main-thread blocking DoS vector under heavy brute-force password traffic.
---
## [v2.0.5] — 2026-06-03
### Security & Hardening
- Hardened extension room idle auto-leave detection to correctly recognize when the target tab's video heartbeat goes stale (e.g., after tab navigation or media closure).
- Exported cleaner graceful shutdown and lifecycle methods (`stopServerForTests`) from the relay server to prevent socket leaks and port-binding conflicts during verify checks.
### Added
- Added a validation step in `test-locales.js` to ensure the supported language list in `extension/i18n.js` is perfectly synchronized with the actual JSON translation files in the locales directory.
- Added a robust route verification test suite (`scripts/test-server-routes.mjs`) covering rate limit throttling, caching headers, and admin metrics access control.
---
## [v2.0.4] — 2026-06-03
### Security & Hardening
- Hardened relay health endpoints against simple flood traffic: `GET /` and `GET /health` are now limited to 10 requests per minute per client IP.
- Added lazy 60-second server-side caching for `GET /`, basic `/health`, and admin `/health` JSON responses to reduce repeated health-check work under noisy polling.
- Added stricter brute-force throttling for invalid admin metrics bearer attempts.
- Added startup warning for short `ADMIN_METRICS_TOKEN` values and documented that production Node ports must stay private behind Caddy or another trusted reverse proxy.
- Lowered the default maximum peers per room to 25.
### Added
- Optional privacy-preserving admin metrics on `/health` when `ADMIN_METRICS_TOKEN` is configured and a valid bearer token is supplied. Metrics are aggregate-only and exclude room IDs, peer IDs, usernames, IP addresses, media titles, passwords, and other user-level data.
### Changed
- Removed `bcryptjs`; temporary room passwords continue to use keyed SHA-256/HMAC hashing as documented.
- Public room discovery is now rate-limited server-side to one refresh every 10 seconds per socket, with the extension refresh button locked for 11 seconds.
### Fixed
- Improved Shadow DOM video detection so real embedded players are not hidden by smaller light-DOM preview or placeholder videos.
- Fixed join-button timeout cleanup after join status responses.
---
## [v2.0.2] — 2026-06-02
### Fixed
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
---
## [v2.0.1] — 2026-06-01
### Fixed
- Video detection on Prime Video: `findVideo()` now scores all video elements by size, duration, and mute state instead of picking the first one. Fixes 0×0 placeholder being selected over the actual player.
- History entries in debug report showing `?` instead of action names.
- Prime Video status in compatibility matrix updated to reflect partial support.
### Added
- Multi-video overview table in Copy Debug Report when a page has more than one `<video>` element. Shows resolution, mute state, playback state, readyState, duration, and marks the currently targeted video.
---
## [v2.0.0] — 2026-06-01
### 🌍 Multi-Language Extension (Biggest Feature!)
- **6-Language UI**: The browser extension is now fully translated into **English, German, French, Spanish, Portuguese (Brazilian), and Russian**. Switch languages instantly in Settings without reload.
- **Real-Time i18n**: Every label, button, tooltip, toast notification, empty state, and onboarding guide updates dynamically when the language changes.
### New Features
- **Copy Debug Report (Markdown)**: The *Copy Logs* button in the Status tab now copies a fully formatted Markdown debug report — system info, connection status, video diagnostics, action history, and logs. One click, paste into a GitHub issue, all debugging data ready.
- **Platform Auto-Detection**: The Dev tab now identifies streaming platforms (YouTube, Netflix, Twitch, Prime Video, Disney+, HBO Max, Vimeo, Dailymotion) and displays the detected platform.
- **Enhanced Video Debug Info**: 20+ new fields in the Status tab including network state, buffered ranges, dimensions (with 0×0 warning), media error codes, shadow DOM status, seeking/ended/loop flags, volume, playback speed, and data attributes.
- **No-Video Diagnostic Mode**: When no video is found, the Status tab shows platform, page title, video count, shadow DOM presence, and MediaSession data to help troubleshoot.
### Changed
- **New TwoPointZero Branding**: Updated extension icons (16/32/48/96/128px).
- **Larger Popup Logo**: Extension popup icon increased to 48px.
- **Prime Video Unblocked**: Removed `amazon.` from the tab blacklist so Amazon/Prime Video tabs appear in the video selector.
- **Improved Debug Report**: Full User-Agent string for accurate browser identification, UTC timestamp, connection details including server URL and room info.
- **Smart Disconnect**: Improved disconnect handling when leaving rooms.
- **Human-Readable Room IDs**: Expanded word lists for friendlier room names.
- **Custom Server Support**: WEB_JOIN_REQUEST and join button for custom server invite flows.
- **Reconnection Strategy**: Custom server reconnection improvements.
- **Episode-Aware Sync**: Command sequencing with smarter episode transition detection and echo suppression for smoother series binges.
- **Sync Status Refinements**: YouTube and Twitch sync behavior improved.
- **No External Dependencies**: Extension remains dependency-free with no library overhead.
### Fixed
- Hardcoded strings, missing translation keys, and Service Worker notification race conditions.
---
## Versioning Policy
- **MAJOR** (x.0.0): Breaking protocol changes, architecture rewrites, or major feature milestones.
- **MINOR** (0.x.0): New features, significant enhancements, new translations, or UI redesigns.
- **PATCH** (0.0.x): Bug fixes, minor improvements, and documentation updates. PATCH releases may not receive individual changelog entries if bundled with a MINOR release.
+2
View File
@@ -2,6 +2,8 @@
**KoalaSync does not collect, store, or sell any personal data.**
*We don't track you. We only track our server* (relying exclusively on aggregated, anonymous, and non-personal system metrics to monitor performance and stability).
KoalaSync is designed with a **Security-First & Volatile** architecture. This means we prioritize keeping your data out of persistent storage, though certain technical data must be processed temporarily to ensure service stability and security.
## 1. Data Processing (In-Memory Only)
+83 -15
View File
@@ -1,22 +1,90 @@
# KoalaSync Roadmap
Dieses Dokument erfasst zukünftige technische Pläne und Optimierungen für das KoalaSync-System.
> Feature priorities, planned work, backlog, and rejected ideas for KoalaSync.
---
## Geplante Optimierungen & Technische Roadmap
## Status Legend
### 1. Behebung des synchronen Sortierungs-Flaschenhalses bei Auth-Failure LRU-Eviction
* **Kategorie**: Performance / DoS-Prävention
* **Hintergrund**: Der Server schützt Räume vor Brute-Force-Angriffen durch die Nachverfolgung fehlerhafter Anmeldeversuche (`failedAuthAttempts` Map). Bei Erreichen des Limits von 50.000 Einträgen wird ein Bereinigungsverfahren gestartet, das die gesamte Map in ein Array konvertiert und dieses per `Array.from().sort()` sortiert.
* **Problem**: Dies blockiert den Node.js-Main-Thread für mehrere Millisekunden und stellt einen potenziellen Denial-of-Service-Vektor (DoS) dar, wenn ein Angreifer gezielt Fehlversuche spamt.
* **Geplante Lösung**:
- Umstellung auf eine echte, $O(1)$-basierte LRU-Cache-Datenstruktur (z. B. doppelt verkettete Liste in Kombination mit einer Map).
- Alternativ: Ein vereinfachtes zeitbasiertes Ablauf-Verfahren oder ein schrittweises Löschen von Segmenten (Chunk-Eviction), um Blockaden des Main-Threads vollständig auszuschließen.
| 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 |
### 2. Aufteilung großer JavaScript-Dateien (> 800 Zeilen) in kleinere Module
* **Kategorie**: Wartbarkeit / AI-Kontext-Optimierung
* **Hintergrund**: Einige Kern-Dateien wie `background.js` und `popup.js` sind stark angewachsen und überschreiten 800 Zeilen. Dies erschwert das manuelle Debugging und verbraucht unnötig viel Kontextfenster bei AI-Modellen.
* **Geplante Lösung**:
- Strukturierte Aufteilung der Logik in separate, fokussierte Module (z. B. UI-Renderer, Message-Router, Storage-Manager, Socket-Client).
- Nutzung von ES-Modulen zur sauberen Strukturierung und besseren Wiederverwendbarkeit.
---
## 🚧 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)* | |
+10 -10
View File
@@ -4,19 +4,19 @@ This document tracks which streaming platforms and media servers have been teste
| Service | Sync Works | Media Title | Episode Auto-Sync | Notes |
|---------|:----------:|:-----------:|:-----------------:|-------|
| **YouTube** | ✅ Full | ✅ Full | ❌ | Best-in-class support. Native player API, reliable title detection. |
| **Twitch** | ✅ Full | ✅ Full | ❌ | Live-only platform. Tested on regular streams. |
| **Netflix** | ✅ Full | ⚠️ Hidden | ⚠️ Manual | Sync works perfectly, but DRM prevents media title detection. Episode transitions may require manual lobby. |
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
| **Plex** | Not tested | Not tested | Not tested | Community reports indicate compatibility via HTML5 player mode. |
| **Disney+** | Not tested | Not tested | Not tested | Widevine DRM may restrict title detection similar to Netflix. |
| **Prime Video** | ⚠️ Partial | ⚠️ Partial | ❌ | Video elements detected (2 on page, picks larger one). Playback state + time readable. However, the preview/trailer video may be selected instead of the main content. Play/Pause commands may not reach the correct player. Title detection from MediaSession API may work for some content. |
| **YouTube** | ✅ Full | ✅ Full | ❌ | Individual videos, not episodes — no episode auto-sync. |
| **Twitch** | ✅ Full | ✅ Full | ❌ | Individual streams/VODs, not episodes — no episode auto-sync. |
| **Netflix** | ✅ Full | ❌ | ❌ | No media title exposed. |
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Best-in-class support. |
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | |
| **Plex** | Not tested | Not tested | Not tested | |
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | Series title only (e.g. "The Simpsons"), no episode info. |
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — |
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
| **Vimeo** | Not tested | Not tested | Not tested | — |
| **Dailymotion** | Not tested | Not tested | Not tested | — |
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | German public broadcasters. HTML5 players expected to work. |
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | |
## Legend
@@ -39,7 +39,7 @@ Tested a service that's not listed? Found different behavior than documented?
KoalaSync works on any website with a **standard HTML5 `<video>` element** that allows script injection.
Limited functionality on certain platforms is typically caused by:
- **DRM/Copy Protection** (e.g., Widevine on Netflix, Disney+) which restricts access to media metadata like title and playback state
- **DRM/Copy Protection** (e.g., Widevine on Netflix) which restricts access to media metadata like title and playback state
- **Shadow DOM encapsulation** that hides video elements from content scripts
- **Strict Content Security Policies** (CSP) that block script injection
+1
View File
@@ -8,6 +8,7 @@ export default [
sourceType: "module",
globals: {
chrome: "readonly",
browser: "readonly",
window: "readonly",
document: "readonly",
navigator: "readonly",
+104
View File
@@ -0,0 +1,104 @@
# Prometheus Community JSON Exporter Configuration Example
# File: examples/json_exporter.example.yml
#
# Use this configuration to map KoalaSync admin health metrics (JSON)
# to native Prometheus metrics.
#
# Usage:
# 1. Rename this file to json_exporter.yml
# 2. Replace "YOUR_ADMIN_METRICS_TOKEN" with your actual ADMIN_METRICS_TOKEN env value
# 3. Mount it to the json-exporter docker container: /config.yml
modules:
koalasync:
http_client_config:
bearer_token: "YOUR_ADMIN_METRICS_TOKEN"
metrics:
- name: koalasync_uptime_seconds
path: '{.uptime}'
help: "Uptime of the KoalaSync relay server in seconds"
- name: koalasync_rooms
path: '{.rooms}'
help: "Total active rooms"
- name: koalasync_connections
path: '{.connections}'
help: "Total active socket connections (sockets)"
- name: koalasync_peers
path: '{.peers}'
help: "Total connected peers across all rooms"
- name: koalasync_rooms_with_lobby
path: '{.roomsWithLobby}'
help: "Number of rooms waiting in an episode lobby"
- name: koalasync_avg_peers_per_room
path: '{.avgPeersPerRoom}'
help: "Average number of peers per room"
- name: koalasync_max_peers_in_room
path: '{.maxPeersInRoom}'
help: "Maximum number of peers in a single room"
- name: koalasync_memory_rss_bytes
path: '{.memory.rss}'
help: "Resident Set Size (RSS) memory usage in bytes"
- name: koalasync_memory_heap_used_bytes
path: '{.memory.heapUsed}'
help: "V8 engine heap used in bytes"
- name: koalasync_memory_heap_total_bytes
path: '{.memory.heapTotal}'
help: "V8 engine heap total in bytes"
# Rate limiter tracking — unique clients currently in each tracking window
# (not rate-limit denials; these include legitimate traffic too)
- name: koalasync_rate_limit_connections
path: '{.rateLimits.trackedClients.connections}'
help: "Unique clients tracked in the connection rate limiter window"
- name: koalasync_rate_limit_events
path: '{.rateLimits.trackedClients.events}'
help: "Unique sockets tracked in the event rate limiter window"
- name: koalasync_rate_limit_health
path: '{.rateLimits.trackedClients.health}'
help: "Unique IPs tracked in the health endpoint rate limiter window"
- name: koalasync_rate_limit_admin_metrics_auth
path: '{.rateLimits.trackedClients.adminMetricsAuth}'
help: "Unique IPs tracked in the admin metrics auth rate limiter window"
- name: koalasync_rate_limit_auth_failures
path: '{.rateLimits.trackedClients.authFailures}'
help: "Unique IPs tracked in the authentication failures cache"
- name: koalasync_rate_limit_room_list
path: '{.rateLimits.trackedClients.roomList}'
help: "Unique sockets in the room list cooldown cache"
# Actual rate-limit denials — incremented only when a 429 is served
- name: koalasync_rate_limit_denied_connections
path: '{.rateLimits.denied.connections}'
help: "Total connection attempts denied by rate limiter"
- name: koalasync_rate_limit_denied_events
path: '{.rateLimits.denied.events}'
help: "Total socket events denied by rate limiter"
- name: koalasync_rate_limit_denied_health
path: '{.rateLimits.denied.health}'
help: "Total health endpoint requests denied by rate limiter"
- name: koalasync_rate_limit_denied_admin_metrics_auth
path: '{.rateLimits.denied.adminMetricsAuth}'
help: "Total admin metrics auth attempts denied by rate limiter"
- name: koalasync_rate_limit_denied_room_list
path: '{.rateLimits.denied.roomList}'
help: "Total room list refresh requests denied by rate limiter"
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincroniza la reproducción de video en YouTube, Netflix, Emby, Jellyfin y cualquier sitio HTML5 en tiempo real con amigos."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin 및 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
}
}
Binary file not shown.
+247
View File
@@ -0,0 +1,247 @@
:root {
--bg: #0f172a;
--card: #1e293b;
--panel: #172033;
--accent: #6366f1;
--accent-hover: #818cf8;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #334155;
--radius: 8px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
}
.page-shell {
width: min(760px, calc(100vw - 32px));
margin: 0 auto;
padding: 32px 0;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.back-link {
display: inline-block;
color: var(--text-muted);
text-decoration: none;
font-size: 12px;
margin-bottom: 6px;
transition: color 0.2s;
}
.back-link:hover {
color: var(--accent);
}
.eyebrow {
margin: 0 0 4px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
h1 {
color: var(--accent-hover);
font-size: 28px;
font-weight: 800;
}
h2 {
font-size: 16px;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.muted-panel {
color: var(--text-muted);
}
.section-heading,
.master-toggle,
.inline-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.master-toggle,
.inline-toggle {
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.preset-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 10px;
margin: 18px 0;
}
.preset-card {
min-height: 44px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.preset-card:hover {
border-color: rgba(99, 102, 241, 0.4);
}
.preset-card:has(input:checked) {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
}
.preset-card input {
accent-color: var(--accent);
}
.custom-grid {
display: grid;
gap: 12px;
padding: 16px;
background: rgba(15, 23, 42, 0.58);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: var(--radius);
transition: opacity 0.3s;
}
.control-row {
display: grid;
grid-template-columns: 110px minmax(160px, 1fr) 78px 30px;
align-items: center;
gap: 10px;
}
.control-row label {
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
input[type="number"] {
width: 100%;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font: inherit;
transition: border-color 0.2s;
}
input[type="number"]:focus {
border-color: var(--accent);
outline: none;
}
.unit {
color: var(--text-muted);
font-size: 12px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 38px;
height: 22px;
flex: 0 0 auto;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: #334155;
transition: .3s;
border-radius: 22px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: #94a3b8;
transition: .3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--accent);
}
input:checked + .slider:before {
transform: translateX(16px);
background-color: white;
}
@media (max-width: 620px) {
.page-header,
.section-heading {
align-items: flex-start;
flex-direction: column;
}
.control-row {
grid-template-columns: 1fr 74px 28px;
}
.control-row label {
grid-column: 1 / -1;
}
}
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>KoalaSync Audio Settings</title>
<link rel="stylesheet" href="audio-options.css">
</head>
<body>
<main class="page-shell">
<header class="page-header">
<div>
<a id="backLink" href="#" class="back-link" data-i18n="AUDIO_BACK">← Back</a>
<p class="eyebrow">KoalaSync</p>
<h1 data-i18n="AUDIO_PAGE_TITLE">Audio Settings</h1>
</div>
<label class="master-toggle">
<span data-i18n="AUDIO_MASTER_TOGGLE">Audio Processing</span>
<span class="toggle-switch">
<input type="checkbox" id="audioEnabled">
<span class="slider"></span>
</span>
</label>
</header>
<section class="panel">
<div class="section-heading">
<h2 data-i18n="AUDIO_COMPRESSOR">Compressor</h2>
<label class="inline-toggle">
<span data-i18n="AUDIO_COMPRESSOR_ENABLE">Enabled</span>
<span class="toggle-switch">
<input type="checkbox" id="compressorEnabled">
<span class="slider"></span>
</span>
</label>
</div>
<div class="preset-group" role="radiogroup" aria-label="Compressor preset">
<label class="preset-card">
<input type="radio" name="preset" value="recommended">
<span data-i18n="AUDIO_PRESET_RECOMMENDED">Recommended</span>
</label>
<label class="preset-card">
<input type="radio" name="preset" value="dynamicRange">
<span data-i18n="AUDIO_PRESET_DYNAMIC_RANGE">Dynamic Range</span>
</label>
<label class="preset-card">
<input type="radio" name="preset" value="vocalEnhancement">
<span data-i18n="AUDIO_PRESET_VOCAL_ENHANCEMENT">Vocal Enhancement</span>
</label>
<label class="preset-card">
<input type="radio" name="preset" value="smooth">
<span data-i18n="AUDIO_PRESET_SMOOTH">Smooth</span>
</label>
<label class="preset-card">
<input type="radio" name="preset" value="custom">
<span data-i18n="AUDIO_PRESET_CUSTOM">Custom</span>
</label>
</div>
<div class="custom-grid" id="customControls">
<div class="control-row" data-param="threshold">
<label data-i18n="AUDIO_PARAM_THRESHOLD">Threshold</label>
<input type="range" min="-60" max="0" step="1">
<input type="number" min="-60" max="0" step="1">
<span class="unit">dB</span>
</div>
<div class="control-row" data-param="knee">
<label data-i18n="AUDIO_PARAM_KNEE">Knee</label>
<input type="range" min="0" max="40" step="1">
<input type="number" min="0" max="40" step="1">
<span class="unit">dB</span>
</div>
<div class="control-row" data-param="ratio">
<label data-i18n="AUDIO_PARAM_RATIO">Ratio</label>
<input type="range" min="1" max="20" step="0.5">
<input type="number" min="1" max="20" step="0.5">
<span class="unit">:1</span>
</div>
<div class="control-row" data-param="attack">
<label data-i18n="AUDIO_PARAM_ATTACK">Attack</label>
<input type="range" min="0" max="1" step="0.001">
<input type="number" min="0" max="1000" step="1" data-ms-input="true">
<span class="unit">ms</span>
</div>
<div class="control-row" data-param="release">
<label data-i18n="AUDIO_PARAM_RELEASE">Release</label>
<input type="range" min="0" max="1" step="0.005">
<input type="number" min="0" max="1000" step="5" data-ms-input="true">
<span class="unit">ms</span>
</div>
</div>
</section>
<section class="panel muted-panel">
<div class="section-heading">
<h2 data-i18n="AUDIO_EQUALIZER">Equalizer</h2>
</div>
<p data-i18n="AUDIO_COMING_SOON">Coming soon</p>
</section>
</main>
<script src="audio-options.js" type="module"></script>
</body>
</html>
+196
View File
@@ -0,0 +1,196 @@
import { loadLocale, translateDOM, getSystemLanguage } from './i18n.js';
const PRESETS = {
recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 },
dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 },
vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 },
smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 },
custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 }
};
const DEFAULT_AUDIO_SETTINGS = {
enabled: false,
compressor: {
enabled: false,
preset: 'recommended',
customParams: { ...PRESETS.custom }
}
};
const PARAM_LIMITS = {
threshold: { min: -60, max: 0 },
knee: { min: 0, max: 40 },
ratio: { min: 1, max: 20 },
attack: { min: 0, max: 1 },
release: { min: 0, max: 1 }
};
const elements = {
audioEnabled: document.getElementById('audioEnabled'),
compressorEnabled: document.getElementById('compressorEnabled'),
presetInputs: Array.from(document.querySelectorAll('input[name="preset"]')),
controlRows: Array.from(document.querySelectorAll('.control-row')),
backLink: document.getElementById('backLink')
};
let saveTimer = null;
let isRendering = false;
function cloneDefaultSettings() {
return JSON.parse(JSON.stringify(DEFAULT_AUDIO_SETTINGS));
}
let currentSettings = cloneDefaultSettings();
function mergeAudioSettings(settings = {}) {
const safeSettings = settings && typeof settings === 'object' ? settings : {};
const defaults = cloneDefaultSettings();
return {
...defaults,
...safeSettings,
compressor: {
...defaults.compressor,
...(safeSettings.compressor || {}),
customParams: {
...defaults.compressor.customParams,
...(safeSettings.compressor?.customParams || {})
}
}
};
}
function debounceSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
chrome.storage.local.set({ audioSettings: currentSettings });
}, 40);
}
function getParamValue(param, value, isMsInput = false) {
const parsed = Number(value);
const candidate = Number.isFinite(parsed)
? (isMsInput ? parsed / 1000 : parsed)
: currentSettings.compressor.customParams[param];
const limits = PARAM_LIMITS[param];
if (!limits) return candidate;
return Math.min(limits.max, Math.max(limits.min, candidate));
}
function formatNumber(value, param, isMsInput = false) {
if (isMsInput) return Math.round(value * 1000);
if (param === 'ratio') return Number(value).toFixed(1).replace(/\.0$/, '');
return value;
}
function render() {
isRendering = true;
elements.audioEnabled.checked = currentSettings.enabled === true;
elements.compressorEnabled.checked = currentSettings.compressor.enabled === true;
const selectedPreset = currentSettings.compressor.preset || 'recommended';
elements.presetInputs.forEach(input => {
input.checked = input.value === selectedPreset;
});
const params = selectedPreset === 'custom'
? currentSettings.compressor.customParams
: PRESETS[selectedPreset] || PRESETS.recommended;
elements.controlRows.forEach(row => {
const param = row.dataset.param;
const range = row.querySelector('input[type="range"]');
const number = row.querySelector('input[type="number"]');
const value = params[param];
range.value = value;
number.value = formatNumber(value, param, number.dataset.msInput === 'true');
});
isRendering = false;
}
function setPreset(preset) {
currentSettings.compressor.preset = preset;
if (preset === 'custom') {
currentSettings.compressor.customParams = {
...PRESETS.custom,
...currentSettings.compressor.customParams
};
}
render();
debounceSave();
}
function setCustomParam(param, value) {
currentSettings.compressor.preset = 'custom';
currentSettings.compressor.customParams[param] = getParamValue(param, value);
render();
debounceSave();
}
async function init() {
let audioData = (await chrome.storage.local.get(['audioSettings'])).audioSettings;
const syncData = await chrome.storage.sync.get(['audioSettings', 'locale']);
if (!audioData && syncData.audioSettings) {
audioData = syncData.audioSettings;
await chrome.storage.local.set({ audioSettings: audioData });
}
const lang = syncData.locale || getSystemLanguage();
await loadLocale(lang);
translateDOM();
currentSettings = mergeAudioSettings(audioData);
render();
}
elements.audioEnabled.addEventListener('change', () => {
currentSettings.enabled = elements.audioEnabled.checked;
if (currentSettings.enabled && !currentSettings.compressor.enabled) {
currentSettings.compressor.enabled = true;
}
render();
debounceSave();
});
elements.compressorEnabled.addEventListener('change', () => {
currentSettings.compressor.enabled = elements.compressorEnabled.checked;
if (currentSettings.compressor.enabled) currentSettings.enabled = true;
render();
debounceSave();
});
elements.presetInputs.forEach(input => {
input.addEventListener('change', () => {
if (input.checked) setPreset(input.value);
});
});
elements.controlRows.forEach(row => {
const param = row.dataset.param;
const range = row.querySelector('input[type="range"]');
const number = row.querySelector('input[type="number"]');
range.addEventListener('input', () => {
if (isRendering) return;
setCustomParam(param, getParamValue(param, range.value));
});
number.addEventListener('input', () => {
if (isRendering) return;
setCustomParam(param, getParamValue(param, number.value, number.dataset.msInput === 'true'));
});
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
currentSettings = mergeAudioSettings(changes.audioSettings.newValue);
render();
});
if (elements.backLink) {
elements.backLink.addEventListener('click', (e) => {
e.preventDefault();
window.close();
});
}
init().catch(err => {
console.error('[AudioOptions] Failed to initialize:', err);
});
+233 -73
View File
@@ -1,7 +1,34 @@
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, SUPPORTED_LANGUAGES } from './i18n.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
// --- 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;
@@ -23,6 +50,12 @@ const lastSeqBySender = {}; // senderId → last received seq (sta
const activePorts = new Set(); // New: track active content ports for keep-alive
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
// --- Ping / Latency ---
let pingInterval = null;
let pingTimeout = null;
let pendingPingT = null;
let currentPingMs = null;
// --- Keep-Alive Port Listener ---
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'keepAlive') {
@@ -230,35 +263,38 @@ async function getPeerId() {
}
async function getSettings() {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
let username = data.username;
if (!username) {
username = generateUsername();
chrome.storage.sync.set({ username }, () => {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username: username
});
});
} else {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username: username
});
}
// Try local (per-device) first, fall back to sync for migration
let data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
let migrated = false;
if (!data.username) {
const syncData = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
if (syncData.username || syncData.roomId) {
data = syncData;
migrated = true;
}
}
let username = data.username;
if (!username) {
username = generateUsername();
}
if (migrated) {
await chrome.storage.local.set({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username
});
});
} else if (!data.username) {
await chrome.storage.local.set({ username });
}
return {
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username
};
}
function addLog(message, type = 'info') {
@@ -296,6 +332,7 @@ function forceDisconnect() {
clearTimeout(forceSyncTimeout);
forceSyncTimeout = null;
}
stopPing();
if (socket) {
socket.onopen = null;
socket.onmessage = null;
@@ -382,7 +419,7 @@ async function leaveRoomAfterIdleGrace(reason) {
lastContentHeartbeatAt: null,
episodeLobby: null
}).catch(() => {});
await chrome.storage.sync.set({ roomId: '', password: '' }).catch(() => {});
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
addLog(reason, 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
updateBadgeStatus();
@@ -457,7 +494,7 @@ async function connect() {
url.pathname = '/socket.io/';
url.searchParams.set('EIO', '4');
url.searchParams.set('transport', 'websocket');
url.searchParams.set('version', APP_VERSION);
url.searchParams.set('version', chrome.runtime.getManifest().version);
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
socket = new WebSocket(url.toString());
@@ -489,6 +526,7 @@ async function connect() {
isConnecting = false;
isNamespaceJoined = true;
broadcastConnectionStatus('connected');
startPing();
addLog('Joined Namespace /', 'success');
const settings = await getSettings();
if (settings.roomId) {
@@ -524,6 +562,7 @@ async function connect() {
socket.onclose = () => {
isConnecting = false;
isNamespaceJoined = false;
stopPing();
isForceSyncInitiator = false;
forceSyncAcks.clear();
@@ -532,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');
@@ -588,14 +627,10 @@ function updateBadgeStatus() {
}
function showNotification(senderName, action) {
chrome.storage.sync.get(['browserNotifications', 'locale'], async (settings) => {
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
if (!settings.browserNotifications) return;
let lang = settings.locale;
if (!lang) {
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
lang = SUPPORTED_LANGUAGES.includes(systemLang) ? systemLang : 'en';
}
const lang = settings.locale || getSystemLanguage();
await loadLocale(lang);
let labelKey = '';
@@ -692,8 +727,50 @@ function addToHistory(action, senderId) {
chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {});
}
// --- Ping / Latency ---
function sendPing() {
const t = Date.now();
pendingPingT = t;
emit(EVENTS.PING, { t });
if (pingTimeout) clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {
if (pendingPingT === t) {
addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn');
pendingPingT = null;
forceDisconnect();
}
pingTimeout = null;
}, 5000);
}
function startPing() {
if (pingInterval) clearInterval(pingInterval);
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
currentPingMs = null;
pendingPingT = null;
pingInterval = setInterval(sendPing, 15000);
sendPing();
}
function stopPing() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
currentPingMs = null;
pendingPingT = null;
}
// --- Event Handlers ---
function handleServerEvent(event, data) {
if (!data) {
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
return;
}
switch (event) {
case EVENTS.ROOM_DATA:
currentRoom = data;
@@ -761,12 +838,9 @@ function handleServerEvent(event, data) {
isConnecting = false;
broadcastConnectionStatus('disconnected');
addLog(`Server Error: ${data.message}`, 'error');
chrome.storage.sync.get(['locale'], async (settings) => {
let lang = settings.locale;
if (!lang) {
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
lang = SUPPORTED_LANGUAGES.includes(systemLang) ? systemLang : 'en';
}
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
if (!settings.browserNotifications) return;
const lang = settings.locale || getSystemLanguage();
await loadLocale(lang);
chrome.notifications.create(`error_${Date.now()}`, {
type: 'basic',
@@ -876,6 +950,11 @@ function handleServerEvent(event, data) {
routeToContent(event, data);
break;
case EVENTS.PING:
if (data && typeof data.t === 'number' && Number.isFinite(data.t) && data.sender) {
emit(EVENTS.PONG, { t: data.t, target: data.sender });
}
break;
case EVENTS.EVENT_ACK:
if (lastActionState && lastActionState.action && data?.senderId) {
// Correlation Check: Only accept ACK if it matches our current action's timestamp
@@ -1012,6 +1091,20 @@ function handleServerEvent(event, data) {
addLog(`Episode lobby for "${title}" cancelled by ${data.senderId || 'peer'}`, 'warn');
}
break;
case EVENTS.PONG:
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
if (pendingPingT === data.t) {
pendingPingT = null;
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
const rtt = Date.now() - data.t;
currentPingMs = (rtt >= 0 && rtt < 30000) ? rtt : null;
chrome.runtime.sendMessage({ type: 'PING_UPDATE', ping: currentPingMs }).catch(() => {});
}
}
break;
default:
addLog(`Received unknown event from server: ${event}`, 'warn');
break;
@@ -1097,14 +1190,10 @@ function cancelEpisodeLobby(reason) {
};
// Chrome notification on failure (per Q2: only notify on failure)
chrome.storage.sync.get(['browserNotifications', 'locale'], async (settings) => {
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
if (!settings.browserNotifications) return;
let lang = settings.locale;
if (!lang) {
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
lang = SUPPORTED_LANGUAGES.includes(systemLang) ? systemLang : 'en';
}
const lang = settings.locale || getSystemLanguage();
await loadLocale(lang);
const reasonKey = reasonKeys[reason];
@@ -1273,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(() => {});
@@ -1297,6 +1384,27 @@ function leaveOldRoomIfSwitching(newRoomId) {
}
}
function resetAudioProcessingInTab(tabId) {
if (!tabId) return;
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
}
async function applyAudioSettingsToTab(tabId) {
if (!tabId) return;
let data = (await chrome.storage.local.get(['audioSettings']));
if (!data.audioSettings) {
const syncData = await chrome.storage.sync.get(['audioSettings']);
if (syncData.audioSettings) {
data = syncData;
await chrome.storage.local.set({ audioSettings: syncData.audioSettings });
}
}
chrome.tabs.sendMessage(tabId, {
action: 'APPLY_AUDIO_SETTINGS',
settings: data.audioSettings
}).catch(() => {});
}
// --- Extension Message Listeners ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
handleAsyncMessage(message, sender, sendResponse);
@@ -1304,18 +1412,31 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
async function handleAsyncMessage(message, sender, sendResponse) {
if (!message) return;
await ensureState();
if (message.type === 'CONNECT') {
const settings = await getSettings();
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();
@@ -1353,11 +1474,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
reconnectSlowMode: reconnectFailed,
roomId: currentRoom ? currentRoom.roomId : null,
serverUrl: currentServerUrl,
version: APP_VERSION,
version: chrome.runtime.getManifest().version,
protocolVersion: PROTOCOL_VERSION,
roomPassword: currentRoom ? currentRoom.password : null
roomPassword: currentRoom ? currentRoom.password : null,
ping: currentPingMs
});
} else if (message.type === 'LEAVE_ROOM') {
resetAudioProcessingInTab(currentTabId);
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
currentTabId = null;
@@ -1403,20 +1526,31 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'WEB_JOIN_REQUEST') {
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
chrome.storage.sync.set({
chrome.storage.local.set({
roomId,
password,
useCustomServer: !!useCustomServer,
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();
@@ -1460,15 +1594,19 @@ async function handleAsyncMessage(message, sender, sendResponse) {
localSeq++;
chrome.storage.session.set({ localSeq });
updateLastAction(message.action, 'You', timestamp);
lastActionState.targetTime = message.payload?.targetTime !== undefined ? message.payload.targetTime : message.payload?.currentTime;
const payload = message.payload || {};
lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
message.payload.actionTimestamp = timestamp;
message.payload.seq = localSeq;
payload.actionTimestamp = timestamp;
payload.seq = localSeq;
message.payload = payload;
// Local Reactive Update
updateLocalPeerState(peerId, {
playbackState: message.action === EVENTS.PLAY ? 'playing' : (message.action === EVENTS.PAUSE ? 'paused' : undefined),
currentTime: message.payload.currentTime !== undefined ? message.payload.currentTime : (message.payload.targetTime !== undefined ? message.payload.targetTime : undefined)
currentTime: payload.currentTime !== undefined ? payload.currentTime : (payload.targetTime !== undefined ? payload.targetTime : undefined)
});
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
@@ -1576,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);
@@ -1599,6 +1738,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
});
} else if (message.type === 'SET_TARGET_TAB') {
const previousTabId = currentTabId;
currentTabId = message.tabId;
currentTabTitle = message.tabTitle;
lastContentHeartbeatAt = null;
@@ -1607,14 +1747,21 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt });
updateBadgeStatus();
if (previousTabId && previousTabId !== currentTabId) {
resetAudioProcessingInTab(previousTabId);
}
if (currentTabId) {
const selectedTabId = currentTabId;
chrome.scripting.executeScript({
target: { tabId: currentTabId },
target: { tabId: selectedTabId },
files: ['content.js']
}).catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
});
})
.then(() => applyAudioSettingsToTab(selectedTabId))
.catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
});
}
sendResponse({ status: 'ok' });
@@ -1638,7 +1785,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
// Check setting
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
if (epSettings.autoSyncNextEpisode === false) {
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
sendResponse({ status: 'disabled' });
@@ -1724,6 +1871,17 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
}
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
await ensureState();
if (!currentTabId) return;
chrome.tabs.sendMessage(currentTabId, {
action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue
}).catch(() => {});
});
// Tab removal listener
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
@@ -1775,7 +1933,9 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).catch(() => {});
})
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
}
});
+3 -1
View File
@@ -10,6 +10,7 @@ document.documentElement.dataset.koalasyncInstalled = 'true';
// 2. Listen for Join Requests from the Website
window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
if (!e || !e.detail) return;
const { roomId, password, useCustomServer, serverUrl } = e.detail;
chrome.runtime.sendMessage({
type: 'WEB_JOIN_REQUEST',
@@ -17,11 +18,12 @@ window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
password,
useCustomServer,
serverUrl
});
}).catch(() => {});
});
// 3. Listen for Status Updates from the Extension and relay to Website
chrome.runtime.onMessage.addListener((msg) => {
if (!msg) return;
if (msg.type === 'JOIN_STATUS') {
const detail = { success: msg.success, message: msg.message };
// Firefox MV3 content scripts run in an isolated world. When dispatching
+225 -18
View File
@@ -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;
@@ -62,13 +63,35 @@
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null;
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
let _audioSettings = null;
let _audioProcessingAllowed = true;
// Cache the autoSyncNextEpisode setting
chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => {
_autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled
chrome.storage.local.get(['autoSyncNextEpisode', 'audioSettings'], (data) => {
if (data.autoSyncNextEpisode === undefined || data.audioSettings === undefined) {
chrome.storage.sync.get(['autoSyncNextEpisode', 'audioSettings'], (syncData) => {
const migrate = {};
if (data.autoSyncNextEpisode === undefined && syncData.autoSyncNextEpisode !== undefined) {
migrate.autoSyncNextEpisode = syncData.autoSyncNextEpisode;
}
if (data.audioSettings === undefined && syncData.audioSettings !== undefined) {
migrate.audioSettings = syncData.audioSettings;
}
if (Object.keys(migrate).length) chrome.storage.local.set(migrate);
_autoSyncEnabled = syncData.autoSyncNextEpisode !== false;
_audioSettings = mergeAudioSettings(syncData.audioSettings);
const v = findVideo();
if (v && _audioProcessingAllowed) applyAudioSettings(v, _audioSettings);
});
return;
}
_autoSyncEnabled = data.autoSyncNextEpisode !== false;
_audioSettings = mergeAudioSettings(data.audioSettings);
const video = findVideo();
if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && changes.autoSyncNextEpisode) {
if (area === 'local' && changes.autoSyncNextEpisode) {
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
}
});
@@ -114,6 +137,149 @@
return best;
}
// --- Audio Processing Module ---
const AUDIO_PRESETS = {
recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 },
dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 },
vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 },
smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 },
custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 }
};
const DEFAULT_AUDIO_SETTINGS = {
enabled: false,
compressor: {
enabled: false,
preset: 'recommended',
customParams: { ...AUDIO_PRESETS.custom }
}
};
let audioCtx = null;
let audioChains = new WeakMap();
let currentAudioVideo = null;
function mergeAudioSettings(settings = {}) {
const safeSettings = settings && typeof settings === 'object' ? settings : {};
return {
...DEFAULT_AUDIO_SETTINGS,
...safeSettings,
compressor: {
...DEFAULT_AUDIO_SETTINGS.compressor,
...(safeSettings.compressor || {}),
customParams: {
...DEFAULT_AUDIO_SETTINGS.compressor.customParams,
...(safeSettings.compressor?.customParams || {})
}
}
};
}
function initAudioContext() {
if (!audioCtx) {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
audioCtx = new AudioContextClass({ latencyHint: 'interactive' });
} catch (e) {
reportLog(`Audio Processing unavailable: ${e.message}`, 'warn');
return null;
}
}
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => { reportLog('AudioContext resume failed - browser may need page interaction first', 'warn'); });
}
return audioCtx;
}
function closeAudioContext() {
if (audioCtx) {
audioCtx.close().catch(() => {});
audioCtx = null;
}
audioChains = new WeakMap();
currentAudioVideo = null;
}
function setupAudioChain(videoEl) {
if (audioChains.has(videoEl)) return audioChains.get(videoEl);
const ctx = initAudioContext();
if (!ctx) return null;
try {
const src = ctx.createMediaElementSource(videoEl);
const compressor = ctx.createDynamicsCompressor();
const dryGain = ctx.createGain();
const compGain = ctx.createGain();
src.connect(dryGain);
dryGain.connect(ctx.destination);
src.connect(compressor);
compressor.connect(compGain);
compGain.connect(ctx.destination);
dryGain.gain.value = 1;
compGain.gain.value = 0;
const chain = { compressor, dryGain, compGain, active: false };
audioChains.set(videoEl, chain);
currentAudioVideo = videoEl;
return chain;
} catch (e) {
reportLog(`Audio Processing setup failed: ${e.message}`, 'warn');
return null;
}
}
function rampGain(node, value, t) {
const current = node.gain.value;
node.gain.cancelScheduledValues(t);
node.gain.setValueAtTime(current, t);
node.gain.linearRampToValueAtTime(value, t + 0.04);
}
function applyAudioBypass(videoEl) {
const chain = audioChains.get(videoEl);
if (!chain || !chain.active) return;
const t = chain.dryGain.context.currentTime;
rampGain(chain.dryGain, 1, t);
rampGain(chain.compGain, 0, t);
chain.active = false;
reportLog('Audio compressor disabled', 'info');
}
function bypassCurrentAudioProcessing() {
if (currentAudioVideo) applyAudioBypass(currentAudioVideo);
}
function applyAudioSettings(videoEl, settings) {
const mergedSettings = mergeAudioSettings(settings);
if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) {
applyAudioBypass(videoEl);
return;
}
const chain = setupAudioChain(videoEl);
if (!chain) return;
const cSettings = mergedSettings.compressor;
const params = cSettings.preset === 'custom'
? cSettings.customParams
: AUDIO_PRESETS[cSettings.preset] || AUDIO_PRESETS.recommended;
chain.compressor.threshold.value = params.threshold ?? -24;
chain.compressor.knee.value = params.knee ?? 15;
chain.compressor.ratio.value = params.ratio ?? 8;
chain.compressor.attack.value = params.attack ?? 0.010;
chain.compressor.release.value = params.release ?? 0.300;
if (!chain.active) {
const t = chain.dryGain.context.currentTime;
rampGain(chain.dryGain, 0, t);
rampGain(chain.compGain, 1, t);
chain.active = true;
reportLog('Audio compressor enabled', 'info');
}
}
// --- Episode Auto-Sync: Detection ---
function getMediaTitle() {
return (navigator.mediaSession && navigator.mediaSession.metadata)
@@ -126,8 +292,8 @@
// Returns null if no episode pattern found.
function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null;
// S01E01 patterns (with optional spaces, dashes, dots between S and E)
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
// S01E01 patterns (with any non-alphanumeric separator between season and E)
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
// "Episode X", "Folge X", "Ep. X", "#X"
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
@@ -273,7 +439,7 @@
ytButton.click();
}
if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
return;
@@ -289,7 +455,7 @@
twitchButton.click();
}
if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
return;
@@ -307,7 +473,7 @@
_setSuppress('paused');
video.pause();
} else if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
} catch (e) {
@@ -344,12 +510,29 @@
// Listen for commands from background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message) return;
if (message.action === 'get_current_time') {
const video = findVideo();
sendResponse({ currentTime: video ? video.currentTime : null });
return true;
}
if (message.action === 'APPLY_AUDIO_SETTINGS') {
_audioProcessingAllowed = true;
_audioSettings = mergeAudioSettings(message.settings);
const video = findVideo();
if (video) applyAudioSettings(video, _audioSettings);
sendResponse({ ok: true });
return true;
}
if (message.action === 'RESET_AUDIO_PROCESSING') {
_audioProcessingAllowed = false;
bypassCurrentAudioProcessing();
sendResponse({ ok: true });
return true;
}
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
let actionCompleted = false;
@@ -366,7 +549,7 @@
if (isDifferentEpisode(senderTitle, myTitle)) {
reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn');
if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) {
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }).catch(() => {});
}
return;
}
@@ -393,9 +576,13 @@
return;
}
_setSuppress('paused');
_setSuppress('seek');
expectedSeekTime = payload.targetTime;
video.pause();
video.currentTime = payload.targetTime;
try {
video.currentTime = payload.targetTime;
} catch (e) {
reportLog(`Force Sync Seek Error: ${e.message}`, 'error');
}
pollSeekReady(payload.targetTime).then((ready) => {
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
if (ready) {
@@ -622,7 +809,7 @@
mediaTitle: mediaTitle,
timestamp: Date.now()
}
});
}).catch(() => {});
// Trigger proactive heartbeat to push stabilized state
scheduleProactiveHeartbeat();
@@ -650,7 +837,15 @@
});
// Reset on page hide/show (bfcache, tab discard)
window.addEventListener('pagehide', () => { pageVisible = false; });
window.addEventListener('pagehide', () => {
pageVisible = false;
closeAudioContext();
if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; }
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
observer.disconnect();
});
window.addEventListener('pageshow', (event) => {
// event.persisted is true ONLY when restored from bfcache, not on initial load
if (event.persisted && !pageVisible) {
@@ -671,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)
@@ -717,6 +918,9 @@
function setupListeners() {
const video = findVideo();
if (video) {
if (currentAudioVideo && currentAudioVideo !== video) {
bypassCurrentAudioProcessing();
}
const existing = video._koalaHandlers;
if (existing) {
video.removeEventListener('play', existing.play);
@@ -736,6 +940,8 @@
if (!lastKnownMediaTitle) {
lastKnownMediaTitle = getMediaTitle();
}
if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
}
}
@@ -750,6 +956,7 @@
if (!video && lastVideoSrc !== undefined) {
reportLog('Video element removed from page', 'warn');
lastVideoSrc = undefined;
closeAudioContext();
return;
}
+54 -7
View File
@@ -1,8 +1,10 @@
// extension/i18n.js
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'];
export const DEFAULT_LANGUAGE = 'en';
let activeDictionary = {};
const dictionaryCache = {};
let currentLanguage = null;
/**
* Resolves, loads, and merges the target language with the English baseline fallback.
@@ -11,13 +13,30 @@ let activeDictionary = {};
export async function loadLocale(langCode) {
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
if (currentLanguage === resolvedLang && Object.keys(activeDictionary).length > 0) {
return;
}
if (dictionaryCache[resolvedLang]) {
activeDictionary = dictionaryCache[resolvedLang];
currentLanguage = resolvedLang;
return;
}
try {
// Load Baseline English
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
const enDict = await enResponse.json();
let enDict;
if (dictionaryCache[DEFAULT_LANGUAGE]) {
enDict = dictionaryCache[DEFAULT_LANGUAGE];
} else {
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
enDict = await enResponse.json();
dictionaryCache[DEFAULT_LANGUAGE] = enDict;
}
if (resolvedLang === DEFAULT_LANGUAGE) {
activeDictionary = enDict;
currentLanguage = resolvedLang;
return;
}
@@ -26,15 +45,27 @@ export async function loadLocale(langCode) {
const targetDict = await targetResponse.json();
// Airtight Fallback Merge: target overrides en, missing elements fallback to en
activeDictionary = Object.assign({}, enDict, targetDict);
const mergedDict = Object.assign({}, enDict, targetDict);
dictionaryCache[resolvedLang] = mergedDict;
activeDictionary = mergedDict;
currentLanguage = resolvedLang;
} catch (err) {
console.error('[i18n] Failed to load locale. Defaulting to English:', err);
// Fallback directly to static English if fetching fails
try {
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
activeDictionary = await enResponse.json();
let enDict;
if (dictionaryCache[DEFAULT_LANGUAGE]) {
enDict = dictionaryCache[DEFAULT_LANGUAGE];
} else {
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
enDict = await enResponse.json();
dictionaryCache[DEFAULT_LANGUAGE] = enDict;
}
activeDictionary = enDict;
currentLanguage = DEFAULT_LANGUAGE;
} catch (_) {
activeDictionary = {};
currentLanguage = null;
}
}
}
@@ -46,7 +77,7 @@ export async function loadLocale(langCode) {
* @returns {string} Translated string or the key itself
*/
export function getMessage(key, placeholders = null) {
let msg = activeDictionary[key] || key;
let msg = activeDictionary[key] !== undefined ? String(activeDictionary[key]) : key;
if (placeholders && typeof placeholders === 'object') {
for (const [k, v] of Object.entries(placeholders)) {
msg = msg.replace(new RegExp(`{${k}}`, 'g'), v);
@@ -87,3 +118,19 @@ export function translateDOM() {
el.setAttribute('placeholder', getMessage(key));
});
}
/**
* Detects and maps the user's system language to the best supported locale.
* @returns {string} Supported language code
*/
export function getSystemLanguage() {
const uiLang = (typeof chrome !== 'undefined' && chrome.i18n && chrome.i18n.getUILanguage)
? chrome.i18n.getUILanguage()
: '';
const fullLang = (navigator.language || uiLang || '').toLowerCase();
if (fullLang.startsWith('pt-br')) {
return 'pt-BR';
}
const baseLang = fullLang.split('-')[0];
return SUPPORTED_LANGUAGES.includes(baseLang) ? baseLang : DEFAULT_LANGUAGE;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+30 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Peer-ID neu generieren",
"BTN_REGEN_ID_TOOLTIP": "Generiere deine interne ID neu und verbinde dich erneut",
"REGEN_ID_DESC": "Verwende dies, wenn du Fehler wegen doppelter Identität siehst.",
"REGEN_ID_OTHER_ISSUE": "Anderes Problem? Öffne ein GitHub Issue",
"LABEL_CONN_STATUS": "Verbindungsstatus",
"LABEL_CONN_STATUS_TOOLTIP": "Aktueller WebSocket-Verbindungsstatus",
"CONN_STATUS_DISCONNECTED": "Getrennt",
@@ -179,5 +180,33 @@
"LOBBY_CANCEL_PEERS_LEFT": "Alle anderen Teilnehmer haben den Raum verlassen",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zeitüberschreitung — nicht alle Teilnehmer haben die Episode geladen",
"LOBBY_CANCEL_USER": "Vom Benutzer abgebrochen",
"NOTIF_ERROR_TITLE": "KoalaSync-Fehler"
"NOTIF_ERROR_TITLE": "KoalaSync-Fehler",
"FOOTER_SUPPORT": "☕ Kauf mir einen Kaffee",
"FOOTER_REVIEW": "★ Bewerten",
"FOOTER_SUPPORT_PROMPT": "Gefällt dir KoalaSync? Hinterlasse eine Bewertung!",
"LABEL_AUDIO_PROCESSING": "Audio-Verarbeitung",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Wendet Audioeffekte wie Kompression auf die Videowiedergabe an",
"AUDIO_OPEN_SETTINGS": "Öffnen",
"NEW_FEATURE_AUDIO": "Neu: Audio-Verarbeitung — probiere den Kompressor aus!",
"AUDIO_BACK": "← Zurück",
"AUDIO_PAGE_TITLE": "Audio-Einstellungen",
"AUDIO_MASTER_TOGGLE": "Audio-Verarbeitung",
"AUDIO_COMPRESSOR": "Kompressor",
"AUDIO_COMPRESSOR_ENABLE": "Aktiviert",
"AUDIO_PRESET": "Preset",
"AUDIO_PRESET_RECOMMENDED": "Empfohlen",
"AUDIO_PRESET_DYNAMIC_RANGE": "Dynamikumfang",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Sprache verbessern",
"AUDIO_PRESET_SMOOTH": "Sanft",
"AUDIO_PRESET_CUSTOM": "Benutzerdefiniert",
"AUDIO_PARAM_THRESHOLD": "Schwellwert",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Demnächst",
"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!"
}
+30 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Regenerate Peer ID",
"BTN_REGEN_ID_TOOLTIP": "Regenerate your internal ID and reconnect",
"REGEN_ID_DESC": "Use this if you see \"Duplicate Identity\" errors.",
"REGEN_ID_OTHER_ISSUE": "Other issue? Open a GitHub Issue",
"LABEL_CONN_STATUS": "Connection Status",
"LABEL_CONN_STATUS_TOOLTIP": "Current WebSocket connection state",
"CONN_STATUS_DISCONNECTED": "Disconnected",
@@ -179,5 +180,33 @@
"LOBBY_CANCEL_PEERS_LEFT": "All other peers left",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — not all peers loaded the episode",
"LOBBY_CANCEL_USER": "Cancelled by user",
"NOTIF_ERROR_TITLE": "KoalaSync Error"
"NOTIF_ERROR_TITLE": "KoalaSync Error",
"FOOTER_SUPPORT": "☕ Buy me a coffee",
"FOOTER_REVIEW": "★ Rate us",
"FOOTER_SUPPORT_PROMPT": "Enjoying KoalaSync? Leave a review!",
"LABEL_AUDIO_PROCESSING": "Audio Processing",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
"AUDIO_OPEN_SETTINGS": "Open",
"NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
"AUDIO_BACK": "← Back",
"AUDIO_PAGE_TITLE": "Audio Settings",
"AUDIO_MASTER_TOGGLE": "Audio Processing",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Enabled",
"AUDIO_PRESET": "Preset",
"AUDIO_PRESET_RECOMMENDED": "Recommended",
"AUDIO_PRESET_DYNAMIC_RANGE": "Dynamic Range",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Vocal Enhancement",
"AUDIO_PRESET_SMOOTH": "Smooth",
"AUDIO_PRESET_CUSTOM": "Custom",
"AUDIO_PARAM_THRESHOLD": "Threshold",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Coming soon",
"BTN_RESTART_TOUR": "Restart Tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Restart the onboarding tutorial",
"HINT_SELECT_VIDEO": "Select your video here!"
}
+30 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Regenerar ID de participante",
"BTN_REGEN_ID_TOOLTIP": "Regenerar tu ID interno y volver a conectarte",
"REGEN_ID_DESC": "Usa esto si ves errores de 'Identidad duplicada'.",
"REGEN_ID_OTHER_ISSUE": "¿Otro problema? Abre un Issue en GitHub",
"LABEL_CONN_STATUS": "Estado de la conexión",
"LABEL_CONN_STATUS_TOOLTIP": "Estado actual de la conexión WebSocket",
"CONN_STATUS_DISCONNECTED": "Desconectado",
@@ -179,5 +180,33 @@
"LOBBY_CANCEL_PEERS_LEFT": "Todos los demás participantes se han ido",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tiempo de espera agotado: no todos los participantes cargaron el episodio",
"LOBBY_CANCEL_USER": "Cancelado por el usuario",
"NOTIF_ERROR_TITLE": "Error de KoalaSync"
"NOTIF_ERROR_TITLE": "Error de KoalaSync",
"FOOTER_SUPPORT": "☕ Invítame a un café",
"FOOTER_REVIEW": "★ Valorar",
"FOOTER_SUPPORT_PROMPT": "¿Te gusta KoalaSync? ¡Deja una reseña!",
"LABEL_AUDIO_PROCESSING": "Procesamiento de audio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efectos de audio como compresión a la reproducción de video",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Nuevo: Procesamiento de audio — ¡prueba el compresor!",
"AUDIO_BACK": "← Volver",
"AUDIO_PAGE_TITLE": "Configuración de audio",
"AUDIO_MASTER_TOGGLE": "Procesamiento de audio",
"AUDIO_COMPRESSOR": "Compresor",
"AUDIO_COMPRESSOR_ENABLE": "Activado",
"AUDIO_PRESET": "Preajuste",
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
"AUDIO_PRESET_DYNAMIC_RANGE": "Rango dinámico",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Mejora de voz",
"AUDIO_PRESET_SMOOTH": "Suave",
"AUDIO_PRESET_CUSTOM": "Personalizado",
"AUDIO_PARAM_THRESHOLD": "Umbral",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Ecualizador",
"AUDIO_COMING_SOON": "Próximamente",
"BTN_RESTART_TOUR": "Reiniciar tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar el tutorial de inicio",
"HINT_SELECT_VIDEO": "¡Selecciona tu video aquí!"
}
+30 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Régénérer l'identifiant",
"BTN_REGEN_ID_TOOLTIP": "Régénérer votre identifiant interne et vous reconnecter",
"REGEN_ID_DESC": "Utilisez cette option si vous rencontrez des erreurs de 'Double identité'.",
"REGEN_ID_OTHER_ISSUE": "Autre problème? Ouvrez un Issue GitHub",
"LABEL_CONN_STATUS": "Statut de la connexion",
"LABEL_CONN_STATUS_TOOLTIP": "Statut actuel de la connexion WebSocket",
"CONN_STATUS_DISCONNECTED": "Déconnecté",
@@ -179,5 +180,33 @@
"LOBBY_CANCEL_PEERS_LEFT": "Tous les autres membres sont partis",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Délai dépassé — tous les membres n'ont pas chargé l'épisode",
"LOBBY_CANCEL_USER": "Annulé par l'utilisateur",
"NOTIF_ERROR_TITLE": "Erreur KoalaSync"
"NOTIF_ERROR_TITLE": "Erreur KoalaSync",
"FOOTER_SUPPORT": "☕ Offre-moi un café",
"FOOTER_REVIEW": "★ Évaluer",
"FOOTER_SUPPORT_PROMPT": "Tu aimes KoalaSync? Laisse un avis!",
"LABEL_AUDIO_PROCESSING": "Traitement audio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applique des effets audio comme la compression à la lecture vidéo",
"AUDIO_OPEN_SETTINGS": "Ouvrir",
"NEW_FEATURE_AUDIO": "Nouveau : Traitement audio — essayez le compresseur !",
"AUDIO_BACK": "← Retour",
"AUDIO_PAGE_TITLE": "Paramètres audio",
"AUDIO_MASTER_TOGGLE": "Traitement audio",
"AUDIO_COMPRESSOR": "Compresseur",
"AUDIO_COMPRESSOR_ENABLE": "Activé",
"AUDIO_PRESET": "Préréglage",
"AUDIO_PRESET_RECOMMENDED": "Recommandé",
"AUDIO_PRESET_DYNAMIC_RANGE": "Plage dynamique",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Amélioration vocale",
"AUDIO_PRESET_SMOOTH": "Douce",
"AUDIO_PRESET_CUSTOM": "Personnalisé",
"AUDIO_PARAM_THRESHOLD": "Seuil",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Égaliseur",
"AUDIO_COMING_SOON": "Bientôt disponible",
"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 !"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "it",
"HTML_CLASS": "lang-it",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Stanza",
"TAB_ROOM_TOOLTIP": "Impostazioni della stanza e connessione",
"TAB_SYNC": "Sincronizza",
"TAB_SYNC_TOOLTIP": "Controlli di sincronizzazione video e azioni remote",
"TAB_SETTINGS": "Impostazioni",
"TAB_SETTINGS_TOOLTIP": "Preferenze dell'estensione",
"TAB_STATUS": "Stato",
"TAB_STATUS_TOOLTIP": "Diagnostica avanzata e log",
"BTN_CREATE_ROOM": "+ Crea Nuova Stanza",
"MANUAL_CONNECT_HEADER": "Connessione Manuale / Avanzata",
"LABEL_SERVER": "Server",
"BTN_SERVER_OFFICIAL": "Ufficiale",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usa il server ufficiale affidabile",
"BTN_SERVER_CUSTOM": "Personalizzato",
"BTN_SERVER_CUSTOM_TOOLTIP": "Connettiti al tuo server ospitato autonomamente",
"PLACEHOLDER_SERVER_URL": "wss://tuo-server:3000",
"LABEL_ROOM_ID": "ID Stanza",
"LABEL_ROOM_ID_TOOLTIP": "L'identificatore univoco per la tua stanza di sincronizzazione",
"PLACEHOLDER_ROOM_ID": "Inserisci ID Stanza",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID univoco della stanza a cui vuoi unirti",
"LABEL_PASSWORD": "Password (Opzionale)",
"LABEL_PASSWORD_TOOLTIP": "Password opzionale per limitare l'accesso alla stanza",
"PLACEHOLDER_PASSWORD": "Password della stanza (opzionale)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password per la stanza (lascia vuoto se nessuna)",
"BTN_JOIN_ROOM": "Entra / Crea Stanza",
"BTN_JOIN_ROOM_TOOLTIP": "Connettiti alla stanza",
"LABEL_PUBLIC_ROOMS": "Stanze Pubbliche",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubblicamente disponibili su questo server",
"BTN_REFRESH": "AGGIORNA",
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze pubbliche",
"PUBLIC_ROOMS_REFRESHING": "Aggiornamento...",
"BTN_REFRESH_COOLDOWN": "ATTENDI {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "L'aggiornamento dell'elenco è in pausa. Riprova tra {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento stanze pubbliche. Prossimo aggiornamento disponibile tra {seconds}s.",
"LABEL_ACTIVE_ROOM": "Stanza Attiva",
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei attualmente connesso",
"ACTIVE_ROOM_NONE": "NESSUNA",
"ACTIVE_SERVER_OFFICIAL": "Server Ufficiale",
"LABEL_INVITE_LINK": "Link di Invito",
"LABEL_INVITE_LINK_TOOLTIP": "Condividi questo link con gli amici per farli entrare",
"LABEL_PEERS_IN_ROOM": "Partecipanti nella Stanza",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Altri utenti attualmente connessi a questa stanza",
"NO_PEERS_CONNECTED": "Nessun partecipante connesso",
"BTN_LEAVE_ROOM": "Lascia Stanza",
"LABEL_SELECT_VIDEO": "Seleziona Video",
"LABEL_SELECT_VIDEO_TOOLTIP": "Scegli la scheda del browser contenente il video da sincronizzare",
"OPTION_SELECT_TAB": "-- Seleziona una Scheda --",
"LABEL_REMOTE_CONTROL": "Controllo Remoto",
"BTN_COPY_INVITE": "📋 Link di Invito",
"BTN_COPY_INVITE_TOOLTIP": "Copia Link di Invito",
"BTN_PLAY": "▶ Riproduci",
"BTN_PLAY_TOOLTIP": "Invia un comando di riproduzione a tutti",
"BTN_PAUSE": "⏸ Pausa",
"BTN_PAUSE_TOOLTIP": "Invia un comando di pausa a tutti",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione di tutti gli utenti",
"OPTION_JUMP_TO_OTHERS": "Salto dagli Altri",
"OPTION_JUMP_TO_ME": "Salto da Me",
"OPTION_JUMP_MODE_TOOLTIP": "Scegli il target di sincronizzazione",
"LABEL_LAST_ACTIVITY": "Stato Ultima Attività",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra il comando più recente di riproduzione, pausa o ricerca",
"NO_RECENT_COMMANDS": "Nessun comando recente",
"LOBBY_HEADER": "LOBBY EPISODIO",
"LOBBY_WAITING_FOR": "🎬 In attesa di: \"{title}\"",
"LOBBY_WAITING_PEERS": "In attesa dei partecipanti...",
"BTN_SKIP_PLAY": "Salta e riproduci comunque",
"BTN_SKIP_PLAY_TOOLTIP": "Annulla la lobby e riproduci comunque",
"LOBBY_CONNECT_FIRST": "Connettiti prima a una stanza",
"LOBBY_CONNECT_FIRST_DESC": "Devi unirti a una stanza tramite un link di invito o crearne una nuova per sincronizzare i video.",
"BTN_CREATE_ROOM_ALT": "Crea Nuova Stanza",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crea una nuova stanza casuale ed entra",
"LABEL_USERNAME": "Tuo Nome Utente",
"LABEL_USERNAME_TOOLTIP": "Il nome utente aiuta gli altri a identificarti.",
"PLACEHOLDER_USERNAME": "Koala Anonimo",
"LABEL_HIDE_CLUTTER": "Nascondi Schede Inutili",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra le schede senza video e i domini non correlati per mantenere pulito l'elenco",
"LABEL_AUTO_SYNC_NEXT": "Auto-Sync Prossimo Episodio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e attende tutti i partecipanti al cambio episodio, poi si avvia in sincro.",
"LABEL_AUTO_COPY_INVITE": "Copia Automatica Invito",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente il link di invito negli appunti quando crei una nuova stanza.",
"LABEL_NOTIFICATIONS": "Notifiche del Browser",
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra le notifiche di sistema quando qualcuno entra/esce o riproduce/mette in pausa.",
"LABEL_LANGUAGE": "Lingua Applicazione",
"LABEL_LANGUAGE_TOOLTIP": "Scegli la lingua preferita per l'estensione",
"LABEL_TROUBLESHOOTING": "Risoluzione Problemi",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Strumenti per risolvere problemi di connessione",
"BTN_REGEN_ID": "Rigenera ID Peer",
"BTN_REGEN_ID_TOOLTIP": "Rigenera il tuo ID interno e riconnettiti",
"REGEN_ID_DESC": "Usa questo se riscontri errori di \"Identità Duplicata\".",
"REGEN_ID_OTHER_ISSUE": "Altro problema? Apri una Issue su GitHub",
"LABEL_CONN_STATUS": "Stato Connessione",
"LABEL_CONN_STATUS_TOOLTIP": "Stato corrente della connessione WebSocket",
"CONN_STATUS_DISCONNECTED": "Disconnesso",
"BTN_RETRY": "RIPROVA",
"BTN_RETRY_TOOLTIP": "Tenta di riconnettersi al server",
"BTN_COPY_LOGS": "Copia Log",
"BTN_COPY_LOGS_TOOLTIP": "Copia i log negli appunti per la condivisione",
"LABEL_VIDEO_DEBUG": "Info Debug Video",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Dettagli tecnici sull'elemento video attualmente selezionato",
"VIDEO_DEBUG_EMPTY": "Nessuna scheda selezionata o video rilevato.",
"LABEL_HISTORY": "Cronologia Azioni Completa",
"LABEL_HISTORY_TOOLTIP": "Registro cronologico di tutti i comandi di sincronizzazione nella stanza",
"HISTORY_EMPTY": "Ancora nessuna attività",
"LABEL_LOGS": "Log (Ultimi 50)",
"LABEL_LOGS_TOOLTIP": "Log di connessione tecnica per il debug",
"BTN_CLEAR": "PULISCI",
"BTN_CLEAR_TOOLTIP": "Cancella l'output del log",
"LABEL_GITHUB": "Repository GitHub",
"BTN_ONBOARDING_SKIP": "Salta",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Salta il tutorial",
"BTN_ONBOARDING_NEXT": "Avanti",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Vai al passaggio successivo",
"ONBOARDING_1_TITLE": "Benvenuto in KoalaSync!",
"ONBOARDING_1_TEXT": "Guarda i video insieme in perfetta sincronia, non importa dove ti trovi. Facciamo un rapido tour!",
"ONBOARDING_2_TITLE": "1. Crea una Stanza",
"ONBOARDING_2_TEXT": "Inizia qui. Crea una stanza e condividi il link di invito con i tuoi amici.",
"ONBOARDING_3_TITLE": "2. Seleziona Video",
"ONBOARDING_3_TEXT": "Naviga qui per selezionare il video che vuoi sincronizzare. Riproduci, metti in pausa e cerca: tutti rimangono sincronizzati.",
"ONBOARDING_4_TITLE": "3. Personalizza",
"ONBOARDING_4_TEXT": "Scegli un nome utente divertente così i tuoi amici sanno chi sei.",
"ONBOARDING_5_TITLE": "Tutto pronto!",
"ONBOARDING_5_TEXT": "È ora di prendere i popcorn. Buona visione insieme!",
"ERR_CONN_TIMEOUT": "Connessione scaduta. Riprova.",
"ERR_INVALID_SERVER_URL": "Formato URL del server non valido.",
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento e riprova.",
"ERR_NO_PEERS_TIME": "Nessun altro peer con un orario noto. Passa a 'Salto da Me'.",
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
"TOAST_INVITE_COPIED": "Link di invito copiato!",
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
"TOAST_LOBBY_SKIPPED": "Lobby dell'episodio saltata.",
"TOAST_LOBBY_SKIP_FAILED": "Impossibile saltare la lobby.",
"TOAST_LOGS_COPIED": "Copiato!",
"TOAST_PEER_JOINED": "{name} è entrato nella stanza",
"TOAST_PEER_LEFT": "{name} ha lasciato la stanza",
"TOAST_PEER_ACTION": "{name} ha {action}",
"STATUS_CONNECTED": "Connesso",
"STATUS_RECONNECTING": "Riconnessione...",
"STATUS_CONNECTING": "Connessione in corso...",
"STATUS_FAILED": "Fallito",
"STATUS_DISCONNECTED": "Disconnesso",
"BTN_STATE_JOINING": "🚀 Entrando...",
"BTN_STATE_RECONNECTING": "🔄 Riconnessione...",
"BTN_STATE_PLAYING": "▶ Riproduzione...",
"BTN_STATE_PAUSING": "⏸ In pausa...",
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione al gruppo ({time})...",
"BTN_STATE_SYNCING": "Sincronizzazione...",
"BTN_STATE_SYNCED": "✅ Sincronizzato!",
"NOTIF_PLAY": "avviato la riproduzione",
"NOTIF_PAUSE": "messo in pausa la riproduzione",
"NOTIF_SEEK": "cercato nel video",
"NOTIF_FORCE_PREPARE": "avviato la sincronizzazione forzata",
"NOTIF_FORCE_EXECUTE": "sincronizzato tutti",
"DEBUG_NO_TAB": "Nessuna scheda di destinazione selezionata.",
"DEBUG_COMM_FAIL": "Impossibile comunicare con il video della scheda.",
"EMPTY_PEERS_TITLE": "Ancora nessun partecipante",
"EMPTY_PEERS_HINT": "Condividi il tuo link di invito per iniziare",
"EMPTY_HISTORY_TITLE": "Ancora nessuna attività",
"EMPTY_HISTORY_HINT": "Riproduci, metti in pausa o cerca per vedere la cronologia",
"EMPTY_LOGS_TITLE": "Nessun log",
"EMPTY_LOGS_HINT": "Gli eventi di connessione appariranno qui",
"EMPTY_ROOMS_TITLE": "Nessuna stanza attiva",
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna per trovare quelle pubbliche",
"LABEL_YOU": "Tu",
"ONBOARDING_DONE": "Fatto!",
"LABEL_LOBBY_PEER_READY": "Pronto",
"LABEL_LOBBY_PEER_LOADING": "Caricamento...",
"LABEL_PASSWORD_PROTECTED": "Protetto da Password",
"LABEL_PEERS_COUNT": "{count} partecipanti",
"LABEL_CUSTOM_SERVER": "Server Personalizzato",
"BTN_STATE_CREATING": "🚀 Creazione Stanza...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronizzazione Episodio Fallita",
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync annullato: {reason}. Potrebbe essere necessaria una sincronizzazione manuale.",
"LOBBY_CANCEL_TIMEOUT": "Timeout",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (ripristinato)",
"LOBBY_CANCEL_PEERS_LEFT": "Tutti gli altri partecipanti hanno lasciato la stanza",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — non tutti i partecipanti hanno caricato l'episodio",
"LOBBY_CANCEL_USER": "Annullato dall'utente",
"NOTIF_ERROR_TITLE": "Errore KoalaSync",
"FOOTER_SUPPORT": "☕ Offrimi un caffè",
"FOOTER_REVIEW": "★ Valuta",
"FOOTER_SUPPORT_PROMPT": "Ti piace KoalaSync? Lascia una recensione!",
"LABEL_AUDIO_PROCESSING": "Elaborazione audio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti audio come la compressione alla riproduzione video",
"AUDIO_OPEN_SETTINGS": "Apri",
"NEW_FEATURE_AUDIO": "Novità: Elaborazione audio — prova il compressore!",
"AUDIO_BACK": "← Indietro",
"AUDIO_PAGE_TITLE": "Impostazioni audio",
"AUDIO_MASTER_TOGGLE": "Elaborazione audio",
"AUDIO_COMPRESSOR": "Compressore",
"AUDIO_COMPRESSOR_ENABLE": "Attivato",
"AUDIO_PRESET": "Preimpostazione",
"AUDIO_PRESET_RECOMMENDED": "Consigliato",
"AUDIO_PRESET_DYNAMIC_RANGE": "Gamma dinamica",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Miglioramento vocale",
"AUDIO_PRESET_SMOOTH": "Morbido",
"AUDIO_PRESET_CUSTOM": "Personalizzato",
"AUDIO_PARAM_THRESHOLD": "Soglia",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizzatore",
"AUDIO_COMING_SOON": "Prossimamente",
"BTN_RESTART_TOUR": "Riavvia tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Riavvia il tutorial iniziale",
"HINT_SELECT_VIDEO": "Seleziona il tuo video qui!"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "ja",
"HTML_CLASS": "lang-ja",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "ルーム",
"TAB_ROOM_TOOLTIP": "ルーム設定と接続",
"TAB_SYNC": "同期",
"TAB_SYNC_TOOLTIP": "ビデオ同期コントロールとリモートアクション",
"TAB_SETTINGS": "設定",
"TAB_SETTINGS_TOOLTIP": "拡張機能の設定",
"TAB_STATUS": "ステータス",
"TAB_STATUS_TOOLTIP": "高度な診断とログ",
"BTN_CREATE_ROOM": "+ 新規ルーム作成",
"MANUAL_CONNECT_HEADER": "手動接続 / 詳細設定",
"LABEL_SERVER": "サーバー",
"BTN_SERVER_OFFICIAL": "公式",
"BTN_SERVER_OFFICIAL_TOOLTIP": "公式の信頼できるサーバーを使用",
"BTN_SERVER_CUSTOM": "カスタム",
"BTN_SERVER_CUSTOM_TOOLTIP": "独自のセルフホストサーバーに接続",
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
"LABEL_ROOM_ID": "ルームID",
"LABEL_ROOM_ID_TOOLTIP": "同期ルームの固有の識別子",
"PLACEHOLDER_ROOM_ID": "ルームIDを入力",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "参加したいルームの固有のID",
"LABEL_PASSWORD": "パスワード(任意)",
"LABEL_PASSWORD_TOOLTIP": "ルームへのアクセスを制限するための任意のパスワード",
"PLACEHOLDER_PASSWORD": "ルームのパスワード(任意)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "ルームのパスワード(ない場合は空欄)",
"BTN_JOIN_ROOM": "ルームに参加 / 作成",
"BTN_JOIN_ROOM_TOOLTIP": "ルームに接続",
"LABEL_PUBLIC_ROOMS": "公開ルーム",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "このサーバー上の公開ルーム의リスト",
"BTN_REFRESH": "更新",
"BTN_REFRESH_TOOLTIP": "公開ルームのリストを更新",
"PUBLIC_ROOMS_REFRESHING": "更新中...",
"BTN_REFRESH_COOLDOWN": "{seconds}秒待機",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "ルームリスト更新のクールダウン中。{seconds}秒後に再試行してください。",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "公開ルームを更新中。次の更新まであと{seconds}秒。",
"LABEL_ACTIVE_ROOM": "アクティブなルーム",
"LABEL_ACTIVE_ROOM_TOOLTIP": "現在接続しているルーム",
"ACTIVE_ROOM_NONE": "なし",
"ACTIVE_SERVER_OFFICIAL": "公式サーバー",
"LABEL_INVITE_LINK": "招待リンク",
"LABEL_INVITE_LINK_TOOLTIP": "友達とこのリンクを共有して参加してもらいましょう",
"LABEL_PEERS_IN_ROOM": "ルーム内のメンバー",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "現在このルームに接続している他のユーザー",
"NO_PEERS_CONNECTED": "接続しているメンバーはいません",
"BTN_LEAVE_ROOM": "ルームを退室",
"LABEL_SELECT_VIDEO": "ビデオを選択",
"LABEL_SELECT_VIDEO_TOOLTIP": "同期するビデオが含まれるブラウザのタブを選択してください",
"OPTION_SELECT_TAB": "-- タブを選択してください --",
"LABEL_REMOTE_CONTROL": "リモートコントロール",
"BTN_COPY_INVITE": "📋 招待リンク",
"BTN_COPY_INVITE_TOOLTIP": "招待リンクをコピー",
"BTN_PLAY": "▶ 再生",
"BTN_PLAY_TOOLTIP": "全員に再生コマンドを送信",
"BTN_PAUSE": "⏸ 一時停止",
"BTN_PAUSE_TOOLTIP": "全員に一時停止コマンドを送信",
"BTN_SYNC": "⚡ 同期",
"BTN_SYNC_TOOLTIP": "すべてのユーザーを強制的に同期",
"OPTION_JUMP_TO_OTHERS": "他の人に合わせる",
"OPTION_JUMP_TO_ME": "自分に合わせる",
"OPTION_JUMP_MODE_TOOLTIP": "同期対象を選択",
"LABEL_LAST_ACTIVITY": "最新のアクティビティ状況",
"LABEL_LAST_ACTIVITY_TOOLTIP": "最新の再生、一時停止、またはシークコマンドを表示します",
"NO_RECENT_COMMANDS": "最近のコマンドはありません",
"LOBBY_HEADER": "エピソードロビー",
"LOBBY_WAITING_FOR": "🎬 待機中: \"{title}\"",
"LOBBY_WAITING_PEERS": "メンバーを待機中...",
"BTN_SKIP_PLAY": "スキップして再生",
"BTN_SKIP_PLAY_TOOLTIP": "ロビーをキャンセルして再生します",
"LOBBY_CONNECT_FIRST": "最初にルームに接続してください",
"LOBBY_CONNECT_FIRST_DESC": "ビデオを同期するには、招待リンクからルームに参加するか、新規に作成する必要があります。",
"BTN_CREATE_ROOM_ALT": "新規ルーム作成",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "ランダムな新規ルームを作成して参加",
"LABEL_USERNAME": "ユーザー名",
"LABEL_USERNAME_TOOLTIP": "ユーザー名は他のメンバーがあなたを識別するのに役立ちます。",
"PLACEHOLDER_USERNAME": "匿名コアラ",
"LABEL_HIDE_CLUTTER": "不要なタブを非表示",
"LABEL_HIDE_CLUTTER_TOOLTIP": "リストをすっきりさせるため、ビデオのないタブや無関係なドメインをフィルタリングします",
"LABEL_AUTO_SYNC_NEXT": "次のエピソードを自動同期",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "エピソード変更時に自動的に一時停止して全員を待ち、準備ができたら同時に再生を開始します。",
"LABEL_AUTO_COPY_INVITE": "招待リンク自動コピー",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "新規ルーム作成時に招待リンクをクリップボードに自動コピーします。",
"LABEL_NOTIFICATIONS": "ブラウザ通知",
"LABEL_NOTIFICATIONS_TOOLTIP": "誰かが参加/退室したとき、または再生/一時停止したときにシステムの通知を表示します。",
"LABEL_LANGUAGE": "アプリの言語",
"LABEL_LANGUAGE_TOOLTIP": "拡張機能の優先言語を選択してください",
"LABEL_TROUBLESHOOTING": "トラブルシューティング",
"LABEL_TROUBLESHOOTING_TOOLTIP": "接続の問題を修正するためのツール",
"BTN_REGEN_ID": "ピアIDの再生成",
"BTN_REGEN_ID_TOOLTIP": "内部IDを再生成して再接続します",
"REGEN_ID_DESC": "「Duplicate Identity」(ID重複)エラーが表示される場合に使用します。",
"REGEN_ID_OTHER_ISSUE": "他の問題?GitHub Issueを開く",
"LABEL_CONN_STATUS": "接続状態",
"LABEL_CONN_STATUS_TOOLTIP": "現在のWebSocket接続状態",
"CONN_STATUS_DISCONNECTED": "切断されました",
"BTN_RETRY": "再試行",
"BTN_RETRY_TOOLTIP": "サーバーへの再接続を試行",
"BTN_COPY_LOGS": "ログをコピー",
"BTN_COPY_LOGS_TOOLTIP": "共有用にログをクリップボードにコピー",
"LABEL_VIDEO_DEBUG": "ビデオデバッグ情報",
"LABEL_VIDEO_DEBUG_TOOLTIP": "現在選択されているビデオ要素に関する技術的詳細",
"VIDEO_DEBUG_EMPTY": "タブが選択されていないか、ビデオが検出されませんでした。",
"LABEL_HISTORY": "全アクション履歴",
"LABEL_HISTORY_TOOLTIP": "ルーム内のすべての同期コマンドの時系列ログ",
"HISTORY_EMPTY": "アクティビティはまだありません",
"LABEL_LOGS": "ログ(直近50件)",
"LABEL_LOGS_TOOLTIP": "デバッグ用の技術接続ログ",
"BTN_CLEAR": "消去",
"BTN_CLEAR_TOOLTIP": "ログ出力をクリア",
"LABEL_GITHUB": "GitHubリポジトリ",
"BTN_ONBOARDING_SKIP": "スキップ",
"BTN_ONBOARDING_SKIP_TOOLTIP": "チュートリアルをスキップ",
"BTN_ONBOARDING_NEXT": "次へ",
"BTN_ONBOARDING_NEXT_TOOLTIP": "次のステップに進む",
"ONBOARDING_1_TITLE": "KoalaSyncへようこそ!",
"ONBOARDING_1_TEXT": "どこにいても、完璧に同期してビデオを一緒に楽しめます。簡単なツアーを始めましょう!",
"ONBOARDING_2_TITLE": "1. ルームの作成",
"ONBOARDING_2_TEXT": "ここからスタート。ルームを作成し、招待リンクを友達に共有します。",
"ONBOARDING_3_TITLE": "2. ビデオの選択",
"ONBOARDING_3_TEXT": "ここで同期したいビデオを選択します。再生、一時停止、シーク操作は全員に同期されます。",
"ONBOARDING_4_TITLE": "3. カスタマイズ",
"ONBOARDING_4_TEXT": "友達にわかりやすいように、楽しいユーザー名を設定しましょう。",
"ONBOARDING_5_TITLE": "準備が整いました!",
"ONBOARDING_5_TEXT": "ポップコーンを用意しましょう。一緒に鑑賞を楽しんでください!",
"ERR_CONN_TIMEOUT": "接続がタイムアウトしました。再試行してください。",
"ERR_INVALID_SERVER_URL": "サーバーURLの形式が無効です。",
"ERR_IDENTITY_NOT_LOADED": "IDがまだロードされていません。しばらく待ってから再試行してください。",
"ERR_NO_PEERS_TIME": "既知の時間を持つ他のメンバーがいません。「自分に合わせる」に切り替えてください。",
"ERR_NO_VIDEO_TAB": "ビデオタブに接続できませんでした。",
"ERR_SELECT_VIDEO": "最初にビデオを選択してください!",
"TOAST_INVITE_COPIED": "招待リンクをコピーしました!",
"TOAST_COPY_FAILED": "クリップボードへのコピーに失敗しました",
"TOAST_LOBBY_SKIPPED": "エピソードロビーをスキップしました。",
"TOAST_LOBBY_SKIP_FAILED": "ロビーのスキップに失敗しました。",
"TOAST_LOGS_COPIED": "コピーしました!",
"TOAST_PEER_JOINED": "{name}が参加しました",
"TOAST_PEER_LEFT": "{name}が退室しました",
"TOAST_PEER_ACTION": "{name}が{action}",
"STATUS_CONNECTED": "接続完了",
"STATUS_RECONNECTING": "再接続中...",
"STATUS_CONNECTING": "接続中...",
"STATUS_FAILED": "失敗",
"STATUS_DISCONNECTED": "切断されました",
"BTN_STATE_JOINING": "🚀 参加中...",
"BTN_STATE_RECONNECTING": "🔄 再接続中...",
"BTN_STATE_PLAYING": "▶ 再生中...",
"BTN_STATE_PAUSING": "⏸ 一時停止中...",
"BTN_STATE_SYNCING_GROUP": "グループに同期中 ({time})...",
"BTN_STATE_SYNCING": "同期中...",
"BTN_STATE_SYNCED": "✅ 同期完了!",
"NOTIF_PLAY": "再生を開始しました",
"NOTIF_PAUSE": "再生を一時停止しました",
"NOTIF_SEEK": "動画をシークしました",
"NOTIF_FORCE_PREPARE": "強制同期を開始しました",
"NOTIF_FORCE_EXECUTE": "全員を同期しました",
"DEBUG_NO_TAB": "対象のタブが選択されていません。",
"DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。",
"EMPTY_PEERS_TITLE": "メンバーはまだいません",
"EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう",
"EMPTY_HISTORY_TITLE": "アクティビティはまだありません",
"EMPTY_HISTORY_HINT": "履歴を表示するには再生、一時停止、またはシークを行います",
"EMPTY_LOGS_TITLE": "ログはありません",
"EMPTY_LOGS_HINT": "接続イベントがここに表示されます",
"EMPTY_ROOMS_TITLE": "アクティブなルームはありません",
"EMPTY_ROOMS_HINT": "ルームを作成するか、更新して公開ルームを探します",
"LABEL_YOU": "あなた",
"ONBOARDING_DONE": "完了!",
"LABEL_LOBBY_PEER_READY": "準備完了",
"LABEL_LOBBY_PEER_LOADING": "読み込み中...",
"LABEL_PASSWORD_PROTECTED": "パスワード保護",
"LABEL_PEERS_COUNT": "{count}人",
"LABEL_CUSTOM_SERVER": "カスタムサーバー",
"BTN_STATE_CREATING": "🚀 ルーム作成中...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — エピソード同期に失敗しました",
"NOTIF_LOBBY_CANCEL_MSG": "自動同期がキャンセルされました: {reason}。手動で同期する必要がある場合があります。",
"LOBBY_CANCEL_TIMEOUT": "タイムアウト",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "タイムアウト(回復済)",
"LOBBY_CANCEL_PEERS_LEFT": "他のすべてのメンバーが退室しました",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "タイムアウト — 一部のメンバーがエピソードを読み込めませんでした",
"LOBBY_CANCEL_USER": "ユーザーによってキャンセルされました",
"NOTIF_ERROR_TITLE": "KoalaSyncエラー",
"FOOTER_SUPPORT": "☕ コーヒーをおごってね",
"FOOTER_REVIEW": "★ 評価する",
"FOOTER_SUPPORT_PROMPT": "KoalaSyncはいかが?レビューを書いてください!",
"LABEL_AUDIO_PROCESSING": "オーディオ処理",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "動画再生に圧縮などのオーディオエフェクトを適用します",
"AUDIO_OPEN_SETTINGS": "開く",
"NEW_FEATURE_AUDIO": "新機能: オーディオ処理 — コンプレッサーを試してみよう!",
"AUDIO_BACK": "← 戻る",
"AUDIO_PAGE_TITLE": "オーディオ設定",
"AUDIO_MASTER_TOGGLE": "オーディオ処理",
"AUDIO_COMPRESSOR": "コンプレッサー",
"AUDIO_COMPRESSOR_ENABLE": "有効",
"AUDIO_PRESET": "プリセット",
"AUDIO_PRESET_RECOMMENDED": "推奨",
"AUDIO_PRESET_DYNAMIC_RANGE": "ダイナミックレンジ",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "ボーカル強調",
"AUDIO_PRESET_SMOOTH": "スムース",
"AUDIO_PRESET_CUSTOM": "カスタム",
"AUDIO_PARAM_THRESHOLD": "スレッショルド",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "イコライザー",
"AUDIO_COMING_SOON": "近日公開",
"BTN_RESTART_TOUR": "チュートリアルを再起動",
"BTN_RESTART_TOUR_TOOLTIP": "オンボーディングチュートリアルを再起動する",
"HINT_SELECT_VIDEO": "ここで動画を選択してください!"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "ko",
"HTML_CLASS": "lang-ko",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "방",
"TAB_ROOM_TOOLTIP": "방 설정 및 연결",
"TAB_SYNC": "동기화",
"TAB_SYNC_TOOLTIP": "비디오 동기화 제어 및 원격 작업",
"TAB_SETTINGS": "설정",
"TAB_SETTINGS_TOOLTIP": "확장 프로그램 기본 설정",
"TAB_STATUS": "상태",
"TAB_STATUS_TOOLTIP": "고급 진단 및 로그",
"BTN_CREATE_ROOM": "+ 새 방 만들기",
"MANUAL_CONNECT_HEADER": "수동 연결 / 고급",
"LABEL_SERVER": "서버",
"BTN_SERVER_OFFICIAL": "공식",
"BTN_SERVER_OFFICIAL_TOOLTIP": "공식적이고 신뢰할 수 있는 서버 사용",
"BTN_SERVER_CUSTOM": "사용자 정의",
"BTN_SERVER_CUSTOM_TOOLTIP": "자체 호스팅 서버에 연결",
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
"LABEL_ROOM_ID": "방 ID",
"LABEL_ROOM_ID_TOOLTIP": "동기화 방의 고유 식별자",
"PLACEHOLDER_ROOM_ID": "방 ID 입력",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "참여하려는 방의 고유 ID",
"LABEL_PASSWORD": "비밀번호 (선택사항)",
"LABEL_PASSWORD_TOOLTIP": "방 액세스를 제한하기 위한 선택적 비밀번호",
"PLACEHOLDER_PASSWORD": "방 비밀번호 (선택사항)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "방 비밀번호 (없으면 비워둠)",
"BTN_JOIN_ROOM": "방 참여 / 만들기",
"BTN_JOIN_ROOM_TOOLTIP": "방에 연결",
"LABEL_PUBLIC_ROOMS": "공개 방",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "이 서버의 공개 방 목록",
"BTN_REFRESH": "Refreschi",
"BTN_REFRESH_TOOLTIP": "공개 방 목록 새로고침",
"PUBLIC_ROOMS_REFRESHING": "새로고침 중...",
"BTN_REFRESH_COOLDOWN": "{seconds}초 대기",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "방 목록 새로고침 대기 중입니다. {seconds}초 후에 다시 시도하세요.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "공개 방 새로고침 중. {seconds}초 후 새로고침 가능.",
"LABEL_ACTIVE_ROOM": "활성 방",
"LABEL_ACTIVE_ROOM_TOOLTIP": "현재 연결된 방",
"ACTIVE_ROOM_NONE": "없음",
"ACTIVE_SERVER_OFFICIAL": "공식 서버",
"LABEL_INVITE_LINK": "초대 링크",
"LABEL_INVITE_LINK_TOOLTIP": "친구들이 참여할 수 있도록 이 링크를 공유하세요",
"LABEL_PEERS_IN_ROOM": "방의 참여자",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "현재 이 방에 연결되어 있는 다른 사용자들",
"NO_PEERS_CONNECTED": "연결된 참여자가 없습니다",
"BTN_LEAVE_ROOM": "방 나가기",
"LABEL_SELECT_VIDEO": "비디오 선택",
"LABEL_SELECT_VIDEO_TOOLTIP": "동기화할 비디오가 포함된 브라우저 탭을 선택하세요",
"OPTION_SELECT_TAB": "-- 탭 선택 --",
"LABEL_REMOTE_CONTROL": "원격 제어",
"BTN_COPY_INVITE": "📋 초대 링크",
"BTN_COPY_INVITE_TOOLTIP": "초대 링크 복사",
"BTN_PLAY": "▶ 재생",
"BTN_PLAY_TOOLTIP": "모든 사람에게 재생 명령 전송",
"BTN_PAUSE": "⏸ 일시정지",
"BTN_PAUSE_TOOLTIP": "모든 사람에게 일시정지 명령 전송",
"BTN_SYNC": "⚡ 동기화",
"BTN_SYNC_TOOLTIP": "모든 사용자의 강제 동기화",
"OPTION_JUMP_TO_OTHERS": "다른 사람에게 이동",
"OPTION_JUMP_TO_ME": "나에게 이동",
"OPTION_JUMP_MODE_TOOLTIP": "동기화 대상 선택",
"LABEL_LAST_ACTIVITY": "최근 활동 상태",
"LABEL_LAST_ACTIVITY_TOOLTIP": "가장 최근의 재생, 일시정지, 탐색 명령을 표시합니다",
"NO_RECENT_COMMANDS": "최근 명령 없음",
"LOBBY_HEADER": "에피소드 로비",
"LOBBY_WAITING_FOR": "🎬 대기 중: \"{title}\"",
"LOBBY_WAITING_PEERS": "참여자 대기 중...",
"BTN_SKIP_PLAY": "건너뛰고 재생",
"BTN_SKIP_PLAY_TOOLTIP": "로비를 취소하고 재생합니다",
"LOBBY_CONNECT_FIRST": "먼저 방에 연결하세요",
"LOBBY_CONNECT_FIRST_DESC": "비디오를 동기화하려면 초대 링크를 통해 방에 참여하거나 새 방을 만들어야 합니다.",
"BTN_CREATE_ROOM_ALT": "새 방 만들기",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "새로운 무작위 방을 만들고 참여",
"LABEL_USERNAME": "사용자 이름",
"LABEL_USERNAME_TOOLTIP": "사용자 이름은 다른 사람들이 귀하를 식별하는 데 도움이 됩니다.",
"PLACEHOLDER_USERNAME": "익명의 코알라",
"LABEL_HIDE_CLUTTER": "복잡한 탭 숨기기",
"LABEL_HIDE_CLUTTER_TOOLTIP": "목록을 깔끔하게 유지하기 위해 비디오가 없는 탭과 관련 없는 도메인을 필터링합니다",
"LABEL_AUTO_SYNC_NEXT": "다음 에피소드 자동 동기화",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "에피소드가 변경되면 자동으로 일시정지하고 모든 참여자를 기다린 후 함께 동기화하여 시작합니다.",
"LABEL_AUTO_COPY_INVITE": "초대 링크 자동 복사",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "새 방을 만들 때 초대 링크를 클립보드에 자동으로 복사합니다.",
"LABEL_NOTIFICATIONS": "브라우저 알림",
"LABEL_NOTIFICATIONS_TOOLTIP": "누군가 참여/퇴장하거나 재생/일시정지할 때 시스템 알림을 표시합니다.",
"LABEL_LANGUAGE": "앱 언어",
"LABEL_LANGUAGE_TOOLTIP": "선호하는 확장 프로그램 언어를 선택하세요",
"LABEL_TROUBLESHOOTING": "문제 해결",
"LABEL_TROUBLESHOOTING_TOOLTIP": "연결 문제 해결을 위한 도구",
"BTN_REGEN_ID": "피어 ID 재생성",
"BTN_REGEN_ID_TOOLTIP": "내부 ID를 재생성하고 다시 연결합니다",
"REGEN_ID_DESC": "\"중복된 ID\" 오류가 발생할 경우에 사용하세요.",
"REGEN_ID_OTHER_ISSUE": "다른 문제? GitHub Issue 열기",
"LABEL_CONN_STATUS": "연결 상태",
"LABEL_CONN_STATUS_TOOLTIP": "현재 WebSocket 연결 상태",
"CONN_STATUS_DISCONNECTED": "연결 끊김",
"BTN_RETRY": "재시도",
"BTN_RETRY_TOOLTIP": "서버에 재연결 시도",
"BTN_COPY_LOGS": "로그 복사",
"BTN_COPY_LOGS_TOOLTIP": "공유를 위해 로그를 클립보드에 복사",
"LABEL_VIDEO_DEBUG": "비디오 디버그 정보",
"LABEL_VIDEO_DEBUG_TOOLTIP": "현재 선택된 비디오 요소에 대한 기술적 세부 정보",
"VIDEO_DEBUG_EMPTY": "선택된 탭이 없거나 비디오가 감지되지 않았습니다.",
"LABEL_HISTORY": "전체 작업 내역",
"LABEL_HISTORY_TOOLTIP": "방 내 모든 동기화 명령의 시간순 로그",
"HISTORY_EMPTY": "활동 없음",
"LABEL_LOGS": "로그 (최근 50개)",
"LABEL_LOGS_TOOLTIP": "디버깅용 기술 연결 로그",
"BTN_CLEAR": "지우기",
"BTN_CLEAR_TOOLTIP": "로그 출력 지우기",
"LABEL_GITHUB": "GitHub 저장소",
"BTN_ONBOARDING_SKIP": "건너뛰기",
"BTN_ONBOARDING_SKIP_TOOLTIP": "튜토리얼 건너뛰기",
"BTN_ONBOARDING_NEXT": "다음",
"BTN_ONBOARDING_NEXT_TOOLTIP": "다음 단계로 이동",
"ONBOARDING_1_TITLE": "KoalaSync에 오신 것을 환영합니다!",
"ONBOARDING_1_TEXT": "어디에 있든 완벽하게 동기화된 비디오를 함께 시청하세요. 간단히 둘러볼까요!",
"ONBOARDING_2_TITLE": "1. 방 만들기",
"ONBOARDING_2_TEXT": "여기서 시작하세요. 방을 만들고 친구들에게 초대 링크를 공유하세요.",
"ONBOARDING_3_TITLE": "2. 비디오 선택",
"ONBOARDING_3_TEXT": "동기화할 비디오를 선택하려면 여기로 이동하세요. 재생, 일시정지, 탐색이 모든 참여자에게 동기화됩니다.",
"ONBOARDING_4_TITLE": "3. 개인화",
"ONBOARDING_4_TEXT": "친구들이 나를 알아볼 수 있도록 재미있는 사용자 이름을 골라보세요.",
"ONBOARDING_5_TITLE": "모든 준비가 끝났습니다!",
"ONBOARDING_5_TEXT": "팝콘을 준비할 시간입니다. 함께 즐겁게 시청하세요!",
"ERR_CONN_TIMEOUT": "연결 시간이 초과되었습니다. 다시 시도해 주세요.",
"ERR_INVALID_SERVER_URL": "올바르지 않은 서버 URL 형식입니다.",
"ERR_IDENTITY_NOT_LOADED": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.",
"ERR_NO_PEERS_TIME": "시간을 알고 있는 다른 피어가 없습니다. '나에게 이동'으로 전환하세요.",
"ERR_NO_VIDEO_TAB": "비디오 탭에 연결할 수 없습니다.",
"ERR_SELECT_VIDEO": "먼저 비디오를 선택하세요!",
"TOAST_INVITE_COPIED": "초대 링크가 복사되었습니다!",
"TOAST_COPY_FAILED": "클립보드 복사에 실패했습니다",
"TOAST_LOBBY_SKIPPED": "에피소드 로비를 건너뛰었습니다.",
"TOAST_LOBBY_SKIP_FAILED": "로비 건너뛰기에 실패했습니다.",
"TOAST_LOGS_COPIED": "복사됨!",
"TOAST_PEER_JOINED": "{name} 님이 방에 참여했습니다",
"TOAST_PEER_LEFT": "{name} 님이 방에서 나갔습니다",
"TOAST_PEER_ACTION": "{name} 님이 {action}",
"STATUS_CONNECTED": "연결됨",
"STATUS_RECONNECTING": "재연결 중...",
"STATUS_CONNECTING": "연kel 중...",
"STATUS_FAILED": "실패",
"STATUS_DISCONNECTED": "연결 끊김",
"BTN_STATE_JOINING": "🚀 참여 중...",
"BTN_STATE_RECONNECTING": "🔄 재연결 중...",
"BTN_STATE_PLAYING": "▶ 재생 중...",
"BTN_STATE_PAUSING": "⏸ 일시정지 중...",
"BTN_STATE_SYNCING_GROUP": "그룹에 동기화 중 ({time})...",
"BTN_STATE_SYNCING": "동기화 중...",
"BTN_STATE_SYNCED": "✅ 동기화됨!",
"NOTIF_PLAY": "재생을 시작했습니다",
"NOTIF_PAUSE": "재생을 일시정지했습니다",
"NOTIF_SEEK": "비디오를 탐색했습니다",
"NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다",
"NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다",
"DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.",
"DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.",
"EMPTY_PEERS_TITLE": "참여자 없음",
"EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요",
"EMPTY_HISTORY_TITLE": "활동 없음",
"EMPTY_HISTORY_HINT": "기록을 보려면 재생, 일시정지 또는 탐색을 해보세요",
"EMPTY_LOGS_TITLE": "로그 없음",
"EMPTY_LOGS_HINT": "연결 이벤트가 여기에 표시됩니다",
"EMPTY_ROOMS_TITLE": "활성 방 없음",
"EMPTY_ROOMS_HINT": "방을 만들거나 새로고침하여 공개 방을 찾으세요",
"LABEL_YOU": "나",
"ONBOARDING_DONE": "완료!",
"LABEL_LOBBY_PEER_READY": "준비",
"LABEL_LOBBY_PEER_LOADING": "로딩 중...",
"LABEL_PASSWORD_PROTECTED": "비밀번호 보호됨",
"LABEL_PEERS_COUNT": "{count}명",
"LABEL_CUSTOM_SERVER": "사용자 정의 서버",
"BTN_STATE_CREATING": "🚀 방 만드는 중...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — 에피소드 동기화 실패",
"NOTIF_LOBBY_CANCEL_MSG": "자동 동기화 취소됨: {reason}. 수동 동기화가 필요할 수 있습니다.",
"LOBBY_CANCEL_TIMEOUT": "시간 초과",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "시간 초과 (복구됨)",
"LOBBY_CANCEL_PEERS_LEFT": "다른 모든 피어가 나갔습니다",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "시간 초과 — 모든 피어가 에피소드를 로드하지 못했습니다",
"LOBBY_CANCEL_USER": "사용자에 의해 취소됨",
"NOTIF_ERROR_TITLE": "KoalaSync 오류",
"FOOTER_SUPPORT": "☕ 커피 한 잔 사주세요",
"FOOTER_REVIEW": "★ 평가하기",
"FOOTER_SUPPORT_PROMPT": "KoalaSync가 마음에 드세요? 리뷰를 남겨주세요!",
"LABEL_AUDIO_PROCESSING": "오디오 처리",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "비디오 재생에 압축과 같은 오디오 효과를 적용합니다",
"AUDIO_OPEN_SETTINGS": "열기",
"NEW_FEATURE_AUDIO": "신규: 오디오 처리 — 컴프레서를 사용해보세요!",
"AUDIO_BACK": "← 뒤로",
"AUDIO_PAGE_TITLE": "오디오 설정",
"AUDIO_MASTER_TOGGLE": "오디오 처리",
"AUDIO_COMPRESSOR": "컴프레서",
"AUDIO_COMPRESSOR_ENABLE": "활성화",
"AUDIO_PRESET": "프리셋",
"AUDIO_PRESET_RECOMMENDED": "추천",
"AUDIO_PRESET_DYNAMIC_RANGE": "다이내믹 레인지",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "음성 강화",
"AUDIO_PRESET_SMOOTH": "부드럽게",
"AUDIO_PRESET_CUSTOM": "사용자 정의",
"AUDIO_PARAM_THRESHOLD": "임계값",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "이퀄라이저",
"AUDIO_COMING_SOON": "출시 예정",
"BTN_RESTART_TOUR": "튜토리얼 다시 시작",
"BTN_RESTART_TOUR_TOOLTIP": "온보딩 튜토리얼을 다시 시작합니다",
"HINT_SELECT_VIDEO": "여기에서 비디오를 선택하세요!"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "nl",
"HTML_CLASS": "lang-nl",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Kamer",
"TAB_ROOM_TOOLTIP": "Kamerinstellingen en verbinding",
"TAB_SYNC": "Sync",
"TAB_SYNC_TOOLTIP": "Videobeheer en externe acties",
"TAB_SETTINGS": "Instellingen",
"TAB_SETTINGS_TOOLTIP": "Extensievoorkeuren",
"TAB_STATUS": "Status",
"TAB_STATUS_TOOLTIP": "Geavanceerde diagnostische gegevens & logs",
"BTN_CREATE_ROOM": "+ Nieuwe kamer maken",
"MANUAL_CONNECT_HEADER": "Handmatig verbinden / Geavanceerd",
"LABEL_SERVER": "Server",
"BTN_SERVER_OFFICIAL": "Officieel",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Gebruik de officiële betrouwbare server",
"BTN_SERVER_CUSTOM": "Aangepast",
"BTN_SERVER_CUSTOM_TOOLTIP": "Verbind met uw eigen gehoste server",
"PLACEHOLDER_SERVER_URL": "wss://uw-server:3000",
"LABEL_ROOM_ID": "Kamer-ID",
"LABEL_ROOM_ID_TOOLTIP": "De unieke identificatie voor uw synchronisatiekamer",
"PLACEHOLDER_ROOM_ID": "Voer kamer-ID in",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Het unieke ID van de kamer waar u aan wilt deelnemen",
"LABEL_PASSWORD": "Wachtwoord (optioneel)",
"LABEL_PASSWORD_TOOLTIP": "Optioneel wachtwoord om toegang tot de kamer te beperken",
"PLACEHOLDER_PASSWORD": "Kamerwachtwoord (optioneel)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Wachtwoord voor de kamer (leeg laten indien geen)",
"BTN_JOIN_ROOM": "Deelnemen / Kamer maken",
"BTN_JOIN_ROOM_TOOLTIP": "Verbinden met de kamer",
"LABEL_PUBLIC_ROOMS": "Openbare kamers",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lijst met openbaar beschikbare kamers op deze server",
"BTN_REFRESH": "VERNIEUWEN",
"BTN_REFRESH_TOOLTIP": "Vernieuw de lijst met openbare kamers",
"PUBLIC_ROOMS_REFRESHING": "Vernieuwen...",
"BTN_REFRESH_COOLDOWN": "WACHT {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Vernieuwen van kamerlijst is aan het afkoelen. Probeer opnieuw in {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Openbare kamers vernieuwen. Volgende verversing beschikbaar in {seconds}s.",
"LABEL_ACTIVE_ROOM": "Actieve kamer",
"LABEL_ACTIVE_ROOM_TOOLTIP": "De kamer waar u momenteel mee bent verbonden",
"ACTIVE_ROOM_NONE": "GEEN",
"ACTIVE_SERVER_OFFICIAL": "Officiële server",
"LABEL_INVITE_LINK": "Uitnodigingslink",
"LABEL_INVITE_LINK_TOOLTIP": "Deel deze link met vrienden zodat ze kunnen deelnemen",
"LABEL_PEERS_IN_ROOM": "Deelnemers in kamer",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere gebruikers die momenteel zijn verbonden met deze kamer",
"NO_PEERS_CONNECTED": "Geen deelnemers verbonden",
"BTN_LEAVE_ROOM": "Kamer verlaten",
"LABEL_SELECT_VIDEO": "Selecteer video",
"LABEL_SELECT_VIDEO_TOOLTIP": "Kies het browsertabblad met de video die u wilt synchroniseren",
"OPTION_SELECT_TAB": "-- Selecteer een tabblad --",
"LABEL_REMOTE_CONTROL": "Afstandsbediening",
"BTN_COPY_INVITE": "📋 Uitnodigingslink",
"BTN_COPY_INVITE_TOOLTIP": "Uitnodigingslink kopiëren",
"BTN_PLAY": "▶ Afspelen",
"BTN_PLAY_TOOLTIP": "Stuur een afspeelopdracht naar iedereen",
"BTN_PAUSE": "⏸ Pauzeren",
"BTN_PAUSE_TOOLTIP": "Stuur een pauzeeropdracht naar iedereen",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Dwing alle gebruikers om te synchroniseren",
"OPTION_JUMP_TO_OTHERS": "Spring naar anderen",
"OPTION_JUMP_TO_ME": "Spring naar mij",
"OPTION_JUMP_MODE_TOOLTIP": "Kies synchronisatiedoel",
"LABEL_LAST_ACTIVITY": "Laatste activiteitsstatus",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Toont de meest recente afspeel-, pauzeer- of zoekopdracht",
"NO_RECENT_COMMANDS": "Geen recente commando's",
"LOBBY_HEADER": "EPISODE LOBBY",
"LOBBY_WAITING_FOR": "🎬 Wachten op: \"{title}\"",
"LOBBY_WAITING_PEERS": "Wachten op deelnemers...",
"BTN_SKIP_PLAY": "Overslaan & toch afspelen",
"BTN_SKIP_PLAY_TOOLTIP": "Lobby annuleren en toch afspelen",
"LOBBY_CONNECT_FIRST": "Verbind eerst met een kamer",
"LOBBY_CONNECT_FIRST_DESC": "U moet deelnemen aan een kamer via een uitnodigingslink of een nieuwe maken om video's te synchroniseren.",
"BTN_CREATE_ROOM_ALT": "Nieuwe kamer maken",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Maak een nieuwe willekeurige kamer en neem deel",
"LABEL_USERNAME": "Uw gebruikersnaam",
"LABEL_USERNAME_TOOLTIP": "Gebruikersnaam helpt anderen u te identificeren.",
"PLACEHOLDER_USERNAME": "Anonieme Koala",
"LABEL_HIDE_CLUTTER": "Overbodige tabbladen verbergen",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtert tabbladen zonder video's en niet-gerelateerde domeinen om de lijst schoon te houden",
"LABEL_AUTO_SYNC_NEXT": "Volgende aflevering automatisch syncen",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pauzeert automatisch en wacht op alle deelnemers wanneer een aflevering verandert, en start dan gezamenlijk gesynchroniseerd.",
"LABEL_AUTO_COPY_INVITE": "Uitnodigingslink automatisch kopiëren",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Kopieert de uitnodigingslink automatisch naar uw klembord bij het maken van een nieuwe kamer.",
"LABEL_NOTIFICATIONS": "Browsernotificaties",
"LABEL_NOTIFICATIONS_TOOLTIP": "Toont systeemnotificaties wanneer iemand deelneemt/verlaat of afspeelt/pauzeert.",
"LABEL_LANGUAGE": "Taal app",
"LABEL_LANGUAGE_TOOLTIP": "Kies uw favoriete taal voor de extensie",
"LABEL_TROUBLESHOOTING": "Problemen oplossen",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Hulpmiddelen voor het oplossen van verbindingsproblemen",
"BTN_REGEN_ID": "Peer-ID opnieuw genereren",
"BTN_REGEN_ID_TOOLTIP": "Genereer uw interne ID opnieuw en maak opnieuw verbinding",
"REGEN_ID_DESC": "Gebruik dit als u fouten ziet over 'Duplicate Identity'.",
"REGEN_ID_OTHER_ISSUE": "Ander probleem? Open een GitHub Issue",
"LABEL_CONN_STATUS": "Verbindingsstatus",
"LABEL_CONN_STATUS_TOOLTIP": "Huidige WebSocket-verbindingsstatus",
"CONN_STATUS_DISCONNECTED": "Verbinding verbroken",
"BTN_RETRY": "OPNIEUW PROBEREN",
"BTN_RETRY_TOOLTIP": "Proberen opnieuw verbinding te maken met de server",
"BTN_COPY_LOGS": "Logs kopiëren",
"BTN_COPY_LOGS_TOOLTIP": "Kopieer logboeken naar het klembord om te delen",
"LABEL_VIDEO_DEBUG": "Video-debuginfo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Technische details over het momenteel geselecteerde video-element",
"VIDEO_DEBUG_EMPTY": "Geen tabblad geselecteerd of video gedetecteerd.",
"LABEL_HISTORY": "Volledige actiegeschiedenis",
"LABEL_HISTORY_TOOLTIP": "Chronologisch logboek van alle synchronisatieopdrachten in de kamer",
"HISTORY_EMPTY": "Nog geen activiteit",
"LABEL_LOGS": "Logs (Laatste 50)",
"LABEL_LOGS_TOOLTIP": "Technische verbindingslogboeken voor debuggen",
"BTN_CLEAR": "WISSEN",
"BTN_CLEAR_TOOLTIP": "Logboekuitvoer wissen",
"LABEL_GITHUB": "GitHub-repository",
"BTN_ONBOARDING_SKIP": "Overslaan",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Sla de tutorial over",
"BTN_ONBOARDING_NEXT": "Volgende",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ga naar de volgende stap",
"ONBOARDING_1_TITLE": "Welkom bij KoalaSync!",
"ONBOARDING_1_TEXT": "Bekijk video's samen in perfecte synchronisatie — waar u ook bent. Laten we een snelle rondleiding nemen!",
"ONBOARDING_2_TITLE": "1. Kamer maken",
"ONBOARDING_2_TEXT": "Begin hier. Maak een kamer en deel de uitnodigingslink met uw vrienden.",
"ONBOARDING_3_TITLE": "2. Selecteer video",
"ONBOARDING_3_TEXT": "Navigeer hiernaartoe om de video te selecteren die u wilt synchroniseren. Spelen, pauzeren en zoeken — iedereen blijft synchroon.",
"ONBOARDING_4_TITLE": "3. Personaliseren",
"ONBOARDING_4_TEXT": "Kies een leuke gebruikersnaam zodat uw vrienden weten wie u bent.",
"ONBOARDING_5_TITLE": "U bent er helemaal klaar voor!",
"ONBOARDING_5_TEXT": "Tijd voor popcorn. Veel plezier met samen kijken!",
"ERR_CONN_TIMEOUT": "Verbinding time-out. Probeer het opnieuw.",
"ERR_INVALID_SERVER_URL": "Ongeldig server URL-formaat.",
"ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.",
"ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.",
"ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.",
"ERR_SELECT_VIDEO": "Selecteer eerst een video!",
"TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!",
"TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt",
"TOAST_LOBBY_SKIPPED": "Episode Lobby overgeslagen.",
"TOAST_LOBBY_SKIP_FAILED": "Lobby overslaan mislukt.",
"TOAST_LOGS_COPIED": "Gekopieerd!",
"TOAST_PEER_JOINED": "{name} neemt deel aan de kamer",
"TOAST_PEER_LEFT": "{name} heeft de kamer verlaten",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Verbonden",
"STATUS_RECONNECTING": "Opnieuw verbinden...",
"STATUS_CONNECTING": "Verbinden...",
"STATUS_FAILED": "Mislukt",
"STATUS_DISCONNECTED": "Verbinding verbroken",
"BTN_STATE_JOINING": "🚀 Deelnemen...",
"BTN_STATE_RECONNECTING": "🔄 Opnieuw verbinden...",
"BTN_STATE_PLAYING": "▶ Afspelen...",
"BTN_STATE_PAUSING": "⏸ Pauzeren...",
"BTN_STATE_SYNCING_GROUP": "Synchroniseren met groep ({time})...",
"BTN_STATE_SYNCING": "Synchroniseren...",
"BTN_STATE_SYNCED": "✅ Gesynchroniseerd!",
"NOTIF_PLAY": "is begonnen met afspelen",
"NOTIF_PAUSE": "heeft afspelen gepauzeerd",
"NOTIF_SEEK": "heeft de video gespoeld",
"NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart",
"NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd",
"DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.",
"DEBUG_COMM_FAIL": "Kon niet cmmuniceren met de videotab.",
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
"EMPTY_HISTORY_TITLE": "Nog geen activiteit",
"EMPTY_HISTORY_HINT": "Speel af, pauzeer of spoel om geschiedenis te zien",
"EMPTY_LOGS_TITLE": "Geen logs",
"EMPTY_LOGS_HINT": "Verbindingsgebeurtenissen verschijnen hier",
"EMPTY_ROOMS_TITLE": "Geen actieve kamers",
"EMPTY_ROOMS_HINT": "Maak een kamer of vernieuw om openbare te vinden",
"LABEL_YOU": "Jij",
"ONBOARDING_DONE": "Klaar!",
"LABEL_LOBBY_PEER_READY": "Gereed",
"LABEL_LOBBY_PEER_LOADING": "Laden...",
"LABEL_PASSWORD_PROTECTED": "Wachtwoord beveiligd",
"LABEL_PEERS_COUNT": "{count} deelnemers",
"LABEL_CUSTOM_SERVER": "Aangepaste server",
"BTN_STATE_CREATING": "🚀 Kamer maken...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Episode Sync mislukt",
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync geannuleerd: {reason}. Mogelijk moet u handmatig synchroniseren.",
"LOBBY_CANCEL_TIMEOUT": "Time-out",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Time-out (hersteld)",
"LOBBY_CANCEL_PEERS_LEFT": "Alle andere deelnemers zijn vertrokken",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Time-out — niet alle deelnemers hebben de aflevering geladen",
"LOBBY_CANCEL_USER": "Geannuleerd door gebruiker",
"NOTIF_ERROR_TITLE": "KoalaSync-fout",
"FOOTER_SUPPORT": "☕ Trakteer op een koffie",
"FOOTER_REVIEW": "★ Beoordelen",
"FOOTER_SUPPORT_PROMPT": "Vind je KoalaSync leuk? Laat een review achter!",
"LABEL_AUDIO_PROCESSING": "Audioverwerking",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Past audio-effecten zoals compressie toe op video-afspelen",
"AUDIO_OPEN_SETTINGS": "Openen",
"NEW_FEATURE_AUDIO": "Nieuw: Audioverwerking — probeer de compressor!",
"AUDIO_BACK": "← Terug",
"AUDIO_PAGE_TITLE": "Audio-instellingen",
"AUDIO_MASTER_TOGGLE": "Audioverwerking",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ingeschakeld",
"AUDIO_PRESET": "Voorinstelling",
"AUDIO_PRESET_RECOMMENDED": "Aanbevolen",
"AUDIO_PRESET_DYNAMIC_RANGE": "Dynamisch bereik",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Stemverbetering",
"AUDIO_PRESET_SMOOTH": "Zacht",
"AUDIO_PRESET_CUSTOM": "Aangepast",
"AUDIO_PARAM_THRESHOLD": "Drempel",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Binnenkort beschikbaar",
"BTN_RESTART_TOUR": "Tutorial opnieuw starten",
"BTN_RESTART_TOUR_TOOLTIP": "Start de introductie-tutorial opnieuw",
"HINT_SELECT_VIDEO": "Selecteer hier je video!"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "pl",
"HTML_CLASS": "lang-pl",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Pokój",
"TAB_ROOM_TOOLTIP": "Ustawienia pokoju i połączenie",
"TAB_SYNC": "Sync",
"TAB_SYNC_TOOLTIP": "Sterowanie synchronizacją wideo i zdalne akcje",
"TAB_SETTINGS": "Ustawienia",
"TAB_SETTINGS_TOOLTIP": "Preferencje rozszerzenia",
"TAB_STATUS": "Status",
"TAB_STATUS_TOOLTIP": "Zaawansowana diagnostyka i logi",
"BTN_CREATE_ROOM": "+ Utwórz nowy pokój",
"MANUAL_CONNECT_HEADER": "Połączenie ręczne / Zaawansowane",
"LABEL_SERVER": "Serwer",
"BTN_SERVER_OFFICIAL": "Oficjalny",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Użyj oficjalnego, niezawodnego serwera",
"BTN_SERVER_CUSTOM": "Własny",
"BTN_SERVER_CUSTOM_TOOLTIP": "Połącz się z własnym serwerem",
"PLACEHOLDER_SERVER_URL": "wss://twoj-serwer:3000",
"LABEL_ROOM_ID": "ID pokoju",
"LABEL_ROOM_ID_TOOLTIP": "Unikalny identyfikator pokoju synchronizacji",
"PLACEHOLDER_ROOM_ID": "Wpisz ID pokoju",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Unikalny identyfikator pokoju, do którego chcesz dołączyć",
"LABEL_PASSWORD": "Hasło (opcjonalnie)",
"LABEL_PASSWORD_TOOLTIP": "Opcjonalne hasło ograniczające dostęp do pokoju",
"PLACEHOLDER_PASSWORD": "Hasło pokoju (opcjonalnie)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Hasło do pokoju (pozostaw puste, jeśli brak)",
"BTN_JOIN_ROOM": "Dołącz / Utwórz pokój",
"BTN_JOIN_ROOM_TOOLTIP": "Połącz z pokojem",
"LABEL_PUBLIC_ROOMS": "Pokoje publiczne",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista publicznie dostępnych pokoi na tym serwerze",
"BTN_REFRESH": "ODŚWIEŻ",
"BTN_REFRESH_TOOLTIP": "Odśwież listę pokoi publicznych",
"PUBLIC_ROOMS_REFRESHING": "Odświeżanie...",
"BTN_REFRESH_COOLDOWN": "CZEKAJ {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Odświeżanie listy pokoi stygnie. Spróbuj ponownie za {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Odświeżanie pokoi publicznych. Następne odświeżenie za {seconds}s.",
"LABEL_ACTIVE_ROOM": "Aktywny pokój",
"LABEL_ACTIVE_ROOM_TOOLTIP": "Pokój, z którym jesteś aktualnie połączony",
"ACTIVE_ROOM_NONE": "BRAK",
"ACTIVE_SERVER_OFFICIAL": "Oficjalny serwer",
"LABEL_INVITE_LINK": "Link do zaproszenia",
"LABEL_INVITE_LINK_TOOLTIP": "Udostępnij ten link znajomym, aby mogli dołączyć",
"LABEL_PEERS_IN_ROOM": "Uczestnicy w pokoju",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Inni użytkownicy aktualnie połączeni z tym pokojem",
"NO_PEERS_CONNECTED": "Brak połączonych uczestników",
"BTN_LEAVE_ROOM": "Opuść pokój",
"LABEL_SELECT_VIDEO": "Wybierz wideo",
"LABEL_SELECT_VIDEO_TOOLTIP": "Wybierz kartę przeglądarki zawierającą wideo do synchronizacji",
"OPTION_SELECT_TAB": "-- Wybierz kartę --",
"LABEL_REMOTE_CONTROL": "Zdalne sterowanie",
"BTN_COPY_INVITE": "📋 Link do zaproszenia",
"BTN_COPY_INVITE_TOOLTIP": "Kopiuj link zaproszenia",
"BTN_PLAY": "▶ Odtwórz",
"BTN_PLAY_TOOLTIP": "Wyślij polecenie odtwarzania do wszystkich",
"BTN_PAUSE": "⏸ Pauza",
"BTN_PAUSE_TOOLTIP": "Wyślij polecenie pauzy do wszystkich",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Wymuś synchronizację wszystkich użytkowników",
"OPTION_JUMP_TO_OTHERS": "Skocz do innych",
"OPTION_JUMP_TO_ME": "Skocz do mnie",
"OPTION_JUMP_MODE_TOOLTIP": "Wybierz cel synchronizacji",
"LABEL_LAST_ACTIVITY": "Status ostatniej aktywności",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Pokazuje najnowsze polecenie odtwarzania, pauzy lub przewijania",
"NO_RECENT_COMMANDS": "Brak ostatnich poleceń",
"LOBBY_HEADER": "LOBBY ODCINKA",
"LOBBY_WAITING_FOR": "🎬 Czekam na: \"{title}\"",
"LOBBY_WAITING_PEERS": "Oczekiwanie na uczestników...",
"BTN_SKIP_PLAY": "Pomiń i odtwórz mimo to",
"BTN_SKIP_PLAY_TOOLTIP": "Anuluj lobby i odtwórz mimo to",
"LOBBY_CONNECT_FIRST": "Najpierw połącz się z pokojem",
"LOBBY_CONNECT_FIRST_DESC": "Musisz dołączyć do pokoju przez link zaproszenia lub utworzyć nowy, aby synchronizować wideo.",
"BTN_CREATE_ROOM_ALT": "Utwórz nowy pokój",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Utwórz nowy losowy pokój i dołącz",
"LABEL_USERNAME": "Twoja nazwa użytkownika",
"LABEL_USERNAME_TOOLTIP": "Nazwa użytkownika pomaga innym Cię zidentyfikować.",
"PLACEHOLDER_USERNAME": "Anonimowy Koala",
"LABEL_HIDE_CLUTTER": "Ukryj zbędne karty",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtruje karty bez wideo i niepowiązane domeny, aby zachować porządek",
"LABEL_AUTO_SYNC_NEXT": "Auto-sync następnego odcinka",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Automatycznie wstrzymuje i czeka na wszystkich uczestników po zmianie odcinka, a następnie uruchamia się wspólnie.",
"LABEL_AUTO_COPY_INVITE": "Auto-kopiowanie zaproszenia",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Automatycznie kopiuje link zaproszenia do schowka po utworzeniu nowego pokoju.",
"LABEL_NOTIFICATIONS": "Powiadomienia przeglądarki",
"LABEL_NOTIFICATIONS_TOOLTIP": "Pokazuje natywne powiadomienia systemowe, gdy ktoś dołącza/odchodzi lub odtwarza/wstrzymuje.",
"LABEL_LANGUAGE": "Język aplikacji",
"LABEL_LANGUAGE_TOOLTIP": "Wybierz preferowany język rozszerzenia",
"LABEL_TROUBLESHOOTING": "Rozwiązywanie problemów",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Narzędzia do rozwiązywania problemów z połączeniem",
"BTN_REGEN_ID": "Wygeneruj ponownie ID",
"BTN_REGEN_ID_TOOLTIP": "Wygeneruj ponownie swój identyfikator i połącz się ponownie",
"REGEN_ID_DESC": "Użyj tego, jeśli widzisz błędy o zduplikowanej tożsamości.",
"REGEN_ID_OTHER_ISSUE": "Inny problem? Otwórz Issue na GitHub",
"LABEL_CONN_STATUS": "Status połączenia",
"LABEL_CONN_STATUS_TOOLTIP": "Bieżący stan połączenia WebSocket",
"CONN_STATUS_DISCONNECTED": "Rozłączono",
"BTN_RETRY": "PONÓW",
"BTN_RETRY_TOOLTIP": "Próba ponownego połączenia z serwerem",
"BTN_COPY_LOGS": "Kopiuj logi",
"BTN_COPY_LOGS_TOOLTIP": "Kopiuj logi do schowka, aby je udostępnić",
"LABEL_VIDEO_DEBUG": "Info debugowania wideo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Szczegóły techniczne wybranego elementu wideo",
"VIDEO_DEBUG_EMPTY": "Nie wybrano karty ani nie wykryto wideo.",
"LABEL_HISTORY": "Pełna historia akcji",
"LABEL_HISTORY_TOOLTIP": "Chronologiczny rejestr wszystkich poleceń synchronizacji w pokoju",
"HISTORY_EMPTY": "Brak aktywności",
"LABEL_LOGS": "Logi (ostatnie 50)",
"LABEL_LOGS_TOOLTIP": "Logi połączenia technicznego do debugowania",
"BTN_CLEAR": "WYCZYŚĆ",
"BTN_CLEAR_TOOLTIP": "Wyczyść dane wyjściowe logów",
"LABEL_GITHUB": "Repozytorium GitHub",
"BTN_ONBOARDING_SKIP": "Pomiń",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Pomiń samouczek",
"BTN_ONBOARDING_NEXT": "Dalej",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Przejdź do następnego kroku",
"ONBOARDING_1_TITLE": "Witaj w KoalaSync!",
"ONBOARDING_1_TEXT": "Oglądaj filmy w idealnej synchronizacji — bez względu na to, gdzie jesteś. Zróbmy szybką wycieczkę!",
"ONBOARDING_2_TITLE": "1. Utwórz pokój",
"ONBOARDING_2_TEXT": "Zacznij tutaj. Utwórz pokój i udostępnij link zaproszenia znajomym.",
"ONBOARDING_3_TITLE": "2. Wybierz wideo",
"ONBOARDING_3_TEXT": "Przejdź tutaj, aby wybrać wideo, które chcesz synchronizować. Odtwarzaj, wstrzymuj i przewijaj — każdy pozostaje zsynchronizowany.",
"ONBOARDING_4_TITLE": "3. Personalizacja",
"ONBOARDING_4_TEXT": "Wybierz fajną nazwę użytkownika, aby znajomi wiedzieli, kim jesteś.",
"ONBOARDING_5_TITLE": "Wszystko gotowe!",
"ONBOARDING_5_TEXT": "Czas na popcorn. Miłego wspólnego oglądania!",
"ERR_CONN_TIMEOUT": "Przekroczono limit czasu połączenia. Spróbuj ponownie.",
"ERR_INVALID_SERVER_URL": "Nieprawidłowy format URL serwera.",
"ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.",
"ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.",
"ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.",
"ERR_SELECT_VIDEO": "Najpierw wybierz wideo!",
"TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!",
"TOAST_COPY_FAILED": "Nie udało się skopiować do schowka",
"TOAST_LOBBY_SKIPPED": "Pominięto lobby odcinka.",
"TOAST_LOBBY_SKIP_FAILED": "Nie udało się pominąć lobby.",
"TOAST_LOGS_COPIED": "Skopiowano!",
"TOAST_PEER_JOINED": "{name} dołączył do pokoju",
"TOAST_PEER_LEFT": "{name} opuścił pokój",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Połączono",
"STATUS_RECONNECTING": "Ponowne łączenie...",
"STATUS_CONNECTING": "Łączenie...",
"STATUS_FAILED": "Nieudane",
"STATUS_DISCONNECTED": "Rozłączono",
"BTN_STATE_JOINING": "🚀 Dołączanie...",
"BTN_STATE_RECONNECTING": "🔄 Ponowne łączenie...",
"BTN_STATE_PLAYING": "▶ Odtwarzanie...",
"BTN_STATE_PAUSING": "⏸ Wstrzymywanie...",
"BTN_STATE_SYNCING_GROUP": "Synchronizacja do grupy ({time})...",
"BTN_STATE_SYNCING": "Synchronizacja...",
"BTN_STATE_SYNCED": "✅ Zsynchronizowano!",
"NOTIF_PLAY": "rozpoczął odtwarzanie",
"NOTIF_PAUSE": "wstrzymał odtwarzanie",
"NOTIF_SEEK": "przewinął wideo",
"NOTIF_FORCE_PREPARE": "wymusił synchronizację",
"NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich",
"DEBUG_NO_TAB": "Nie wybrano karty docelowej.",
"DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.",
"EMPTY_PEERS_TITLE": "Brak uczestników",
"EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć",
"EMPTY_HISTORY_TITLE": "Brak aktywności",
"EMPTY_HISTORY_HINT": "Odtwórz, wstrzymaj lub przewiń, aby zobaczyć historię",
"EMPTY_LOGS_TITLE": "Brak logów",
"EMPTY_LOGS_HINT": "Zdarzenia połączenia pojawią się tutaj",
"EMPTY_ROOMS_TITLE": "Brak aktywnych pokoi",
"EMPTY_ROOMS_HINT": "Utwórz pokój lub odśwież, aby znaleźć publiczne",
"LABEL_YOU": "Ty",
"ONBOARDING_DONE": "Gotowe!",
"LABEL_LOBBY_PEER_READY": "Gotowy",
"LABEL_LOBBY_PEER_LOADING": "Ładowanie...",
"LABEL_PASSWORD_PROTECTED": "Chronione hasłem",
"LABEL_PEERS_COUNT": "{count} uczestników",
"LABEL_CUSTOM_SERVER": "Własny serwer",
"BTN_STATE_CREATING": "🚀 Tworzenie pokoju...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Synchronizacja odcinka nieudana",
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync anulowane: {reason}. Może być konieczna ręczna synchronizacja.",
"LOBBY_CANCEL_TIMEOUT": "Limit czasu",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Limit czasu (przywrócono)",
"LOBBY_CANCEL_PEERS_LEFT": "Wszyscy inni uczestnicy wyszli",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Limit czasu — nie wszyscy uczestnicy załadowali odcinek",
"LOBBY_CANCEL_USER": "Anulowane przez użytkownika",
"NOTIF_ERROR_TITLE": "Błąd KoalaSync",
"FOOTER_SUPPORT": "☕ Postaw kawę",
"FOOTER_REVIEW": "★ Oceń",
"FOOTER_SUPPORT_PROMPT": "Podoba Ci się KoalaSync? Zostaw recenzję!",
"LABEL_AUDIO_PROCESSING": "Przetwarzanie dźwięku",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Stosuje efekty dźwiękowe, takie jak kompresja, do odtwarzania wideo",
"AUDIO_OPEN_SETTINGS": "Otwórz",
"NEW_FEATURE_AUDIO": "Nowość: Przetwarzanie dźwięku — wypróbuj kompresor!",
"AUDIO_BACK": "← Wstecz",
"AUDIO_PAGE_TITLE": "Ustawienia dźwięku",
"AUDIO_MASTER_TOGGLE": "Przetwarzanie dźwięku",
"AUDIO_COMPRESSOR": "Kompresor",
"AUDIO_COMPRESSOR_ENABLE": "Włączony",
"AUDIO_PRESET": "Preset",
"AUDIO_PRESET_RECOMMENDED": "Zalecany",
"AUDIO_PRESET_DYNAMIC_RANGE": "Zakres dynamiki",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Wzmocnienie głosu",
"AUDIO_PRESET_SMOOTH": "Płynny",
"AUDIO_PRESET_CUSTOM": "Niestandardowy",
"AUDIO_PARAM_THRESHOLD": "Próg",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Korektor",
"AUDIO_COMING_SOON": "Wkrótce",
"BTN_RESTART_TOUR": "Uruchom samouczek ponownie",
"BTN_RESTART_TOUR_TOOLTIP": "Uruchom ponownie samouczek powitalny",
"HINT_SELECT_VIDEO": "Wybierz swoje wideo tutaj!"
}
+30 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Regenerar ID de participante",
"BTN_REGEN_ID_TOOLTIP": "Regenerar seu ID interno e conectar novamente",
"REGEN_ID_DESC": "Use esta opção se notar erros de 'Identidade duplicada'.",
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um Issue no GitHub",
"LABEL_CONN_STATUS": "Status da conexão",
"LABEL_CONN_STATUS_TOOLTIP": "Status atual da conexão WebSocket",
"CONN_STATUS_DISCONNECTED": "Desconectado",
@@ -179,5 +180,33 @@
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo limite esgotado — nem todos os participantes carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo usuário",
"NOTIF_ERROR_TITLE": "Erro do KoalaSync"
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
"FOOTER_SUPPORT": "☕ Me pague um café",
"FOOTER_REVIEW": "★ Avaliar",
"FOOTER_SUPPORT_PROMPT": "Gostou do KoalaSync? Deixe uma avaliação!",
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio como compressão à reprodução de vídeo",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
"AUDIO_BACK": "← Voltar",
"AUDIO_PAGE_TITLE": "Configurações de áudio",
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ativado",
"AUDIO_PRESET": "Predefinição",
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
"AUDIO_PRESET_DYNAMIC_RANGE": "Faixa dinâmica",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Aprimoramento de voz",
"AUDIO_PRESET_SMOOTH": "Suave",
"AUDIO_PRESET_CUSTOM": "Personalizado",
"AUDIO_PARAM_THRESHOLD": "Limiar",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizador",
"AUDIO_COMING_SOON": "Em breve",
"BTN_RESTART_TOUR": "Reiniciar tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial de introdução",
"HINT_SELECT_VIDEO": "Selecione seu vídeo aqui!"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "pt",
"HTML_CLASS": "lang-pt",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Sala",
"TAB_ROOM_TOOLTIP": "Definições da sala e ligação",
"TAB_SYNC": "Sincronização",
"TAB_SYNC_TOOLTIP": "Controlo de sincronização de vídeo e ações remotas",
"TAB_SETTINGS": "Definições",
"TAB_SETTINGS_TOOLTIP": "Preferências da extensão",
"TAB_STATUS": "Estado",
"TAB_STATUS_TOOLTIP": "Diagnóstico avanzado e registos",
"BTN_CREATE_ROOM": "+ Criar Nova Sala",
"MANUAL_CONNECT_HEADER": "Ligação Manual / Avançado",
"LABEL_SERVER": "Servidor",
"BTN_SERVER_OFFICIAL": "Oficial",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar o servidor oficial fiável",
"BTN_SERVER_CUSTOM": "Personalizado",
"BTN_SERVER_CUSTOM_TOOLTIP": "Ligar ao seu próprio servidor auto-hospedado",
"PLACEHOLDER_SERVER_URL": "wss://o-seu-servidor:3000",
"LABEL_ROOM_ID": "ID da Sala",
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para a sua sala de sincronização",
"PLACEHOLDER_ROOM_ID": "Introduzir ID da Sala",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala à qual deseja aderir",
"LABEL_PASSWORD": "Palavra-passe (Opcional)",
"LABEL_PASSWORD_TOOLTIP": "Palavra-passe opcional para restringir o acesso à sala",
"PLACEHOLDER_PASSWORD": "Palavra-passe da sala (opcional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Palavra-passe para la sala (deixar em branco se não houver)",
"BTN_JOIN_ROOM": "Entrar / Criar Sala",
"BTN_JOIN_ROOM_TOOLTIP": "Ligar à sala",
"LABEL_PUBLIC_ROOMS": "Salas Públicas",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponíveis neste servidor",
"BTN_REFRESH": "ATUALIZAR",
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
"PUBLIC_ROOMS_REFRESHING": "A atualizar...",
"BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A atualização da lista de salas está em espera. Tente novamente em {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "A atualizar salas públicas. Próxima atualização disponível em {seconds}s.",
"LABEL_ACTIVE_ROOM": "Sala Ativa",
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual está atualmente ligado",
"ACTIVE_ROOM_NONE": "NENHUMA",
"ACTIVE_SERVER_OFFICIAL": "Servidor Oficial",
"LABEL_INVITE_LINK": "Link de Invito",
"LABEL_INVITE_LINK_TOOLTIP": "Partilhar este link com os amigos para que possam entrar",
"LABEL_PEERS_IN_ROOM": "Participantes na Sala",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros utilizadores attualmente ligados a questa sala",
"NO_PEERS_CONNECTED": "Nenhum partecipante ligado",
"BTN_LEAVE_ROOM": "Sair della Sala",
"LABEL_SELECT_VIDEO": "Selecionar Vídeo",
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolher o separador do navegador que contém o vídeo a sincronizar",
"OPTION_SELECT_TAB": "-- Selecionar um Separador --",
"LABEL_REMOTE_CONTROL": "Controlo Remoto",
"BTN_COPY_INVITE": "📋 Link de Invito",
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Invito",
"BTN_PLAY": "▶ Reproduzir",
"BTN_PLAY_TOOLTIP": "Enviar um comando de Reproduzir para todos",
"BTN_PAUSE": "⏸ Pausa",
"BTN_PAUSE_TOOLTIP": "Enviar um comando de Pausa para todos",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Forçar todos os utilizadores a sincronizarem-se",
"OPTION_JUMP_TO_OTHERS": "Ir para os Outros",
"OPTION_JUMP_TO_ME": "Ir para Mim",
"OPTION_JUMP_MODE_TOOLTIP": "Escolher o destino de sincronização",
"LABEL_LAST_ACTIVITY": "Estado da Última Atividade",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausa ou busca",
"NO_RECENT_COMMANDS": "Nenhum comando recente",
"LOBBY_HEADER": "LOBBY DE EPISÓDIO",
"LOBBY_WAITING_FOR": "🎬 A aguardar: \"{title}\"",
"LOBBY_WAITING_PEERS": "A aguardar participantes...",
"BTN_SKIP_PLAY": "Ignorar e reproduzir mesmo assim",
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar o lobby e reproduzir mesmo assim",
"LOBBY_CONNECT_FIRST": "Ligar-se primeiro a uma sala",
"LOBBY_CONNECT_FIRST_DESC": "Precisa de entrar numa sala através de um link de convite ou criar uma nova para sincronizar vídeos.",
"BTN_CREATE_ROOM_ALT": "Criar Nova Sala",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Criar uma nova sala aleatória e entrar nela",
"LABEL_USERNAME": "O seu Nome de Utilizador",
"LABEL_USERNAME_TOOLTIP": "O nome de utilizador ajuda os outros a identificá-lo.",
"PLACEHOLDER_USERNAME": "Coala Anónimo",
"LABEL_HIDE_CLUTTER": "Ocultar Separadores Desnecessários",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra separadores sem vídeo e domínios não relacionados para manter a lista limpa",
"LABEL_AUTO_SYNC_NEXT": "Sincronização Automática do Próximo Episódio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda por todos os participantes quando um episódio muda, iniciando depois a reprodução em sincronia.",
"LABEL_AUTO_COPY_INVITE": "Copiar Automaticamente o Link de Convite",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link de convite para a área de transferência ao criar uma nova sala.",
"LABEL_NOTIFICATIONS": "Notificações do Navegador",
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notificações do sistema quando alguém entra/sai ou reproduz/pausa.",
"LABEL_LANGUAGE": "Idioma da App",
"LABEL_LANGUAGE_TOOLTIP": "Escolha o seu idioma de preferência para a extensão",
"LABEL_TROUBLESHOOTING": "Resolução de Problemas",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para corrigir problemas de ligação",
"BTN_REGEN_ID": "Regerar ID do Peer",
"BTN_REGEN_ID_TOOLTIP": "Regerar o seu ID interno e voltar a ligar",
"REGEN_ID_DESC": "Use isto se vir erros de \"Identidade Duplicata\".",
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um Issue no GitHub",
"LABEL_CONN_STATUS": "Estado da Ligação",
"LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação WebSocket",
"CONN_STATUS_DISCONNECTED": "Desligado",
"BTN_RETRY": "TENTAR NOVAMENTE",
"BTN_RETRY_TOOLTIP": "Tentar voltar a ligar ao servidor",
"BTN_COPY_LOGS": "Copiar Registos",
"BTN_COPY_LOGS_TOOLTIP": "Copiar registos para a área de transferência para partilha",
"LABEL_VIDEO_DEBUG": "Info de Debug de Vídeo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o elemento de vídeo atualmente seleccionado",
"VIDEO_DEBUG_EMPTY": "Nenhum separador selecionado ou vídeo detetado.",
"LABEL_HISTORY": "Histórico Completo de Ações",
"LABEL_HISTORY_TOOLTIP": "Registo cronológico de todos os comandos de sincronização na sala",
"HISTORY_EMPTY": "Nenhuma atividade ainda",
"LABEL_LOGS": "Registos (Últimos 50)",
"LABEL_LOGS_TOOLTIP": "Registos de ligação técnica para depuração",
"BTN_CLEAR": "LIMPAR",
"BTN_CLEAR_TOOLTIP": "Limpar a saída do registo",
"LABEL_GITHUB": "Repositório GitHub",
"BTN_ONBOARDING_SKIP": "Saltar",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Saltar o tutorial",
"BTN_ONBOARDING_NEXT": "Seguinte",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir para o passo seguinte",
"ONBOARDING_1_TITLE": "Bem-vindo ao KoalaSync!",
"ONBOARDING_1_TEXT": "Veja vídeos em conjunto numa sincronizzazione perfetta — não importa onde esteja. Vamos fare uma breve visita guiada!",
"ONBOARDING_2_TITLE": "1. Criar uma Sala",
"ONBOARDING_2_TEXT": "Comece aqui. Crie uma sala e partilhe o link de convite com os seus amigos.",
"ONBOARDING_3_TITLE": "2. Selecionar Vídeo",
"ONBOARDING_3_TEXT": "Navegue até aqui para selecionar o vídeo que deseja sincronizar. Reproduza, pause e avance — todos ficam em sincronia.",
"ONBOARDING_4_TITLE": "3. Personalizar",
"ONBOARDING_4_TEXT": "Escolha um nome de utilizador divertido para que os seus amigos saibam quem você é.",
"ONBOARDING_5_TITLE": "Está tudo pronto!",
"ONBOARDING_5_TEXT": "Altura de ir buscar pipocas. Divirtam-se a ver juntos!",
"ERR_CONN_TIMEOUT": "Ligação expirada. Tente novamente.",
"ERR_INVALID_SERVER_URL": "Formato de URL do Servidor inválido.",
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento e tente novamente.",
"ERR_NO_PEERS_TIME": "Nenhum outro peer com hora conhecida. Mude para 'Ir para Mim'.",
"ERR_NO_VIDEO_TAB": "Não foi possível ligar ao separador do vídeo.",
"ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
"TOAST_LOBBY_SKIPPED": "Lobby do episódio ignorado.",
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar o lobby.",
"TOAST_LOGS_COPIED": "Copiado!",
"TOAST_PEER_JOINED": "{name} entrou na sala",
"TOAST_PEER_LEFT": "{name} saiu da sala",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Ligado",
"STATUS_RECONNECTING": "A voltar a ligar...",
"STATUS_CONNECTING": "A ligar...",
"STATUS_FAILED": "Falhou",
"STATUS_DISCONNECTED": "Desligado",
"BTN_STATE_JOINING": "🚀 A entrar...",
"BTN_STATE_RECONNECTING": "🔄 A voltar a ligar...",
"BTN_STATE_PLAYING": "▶ A reproduzir...",
"BTN_STATE_PAUSING": "⏸ A pausar...",
"BTN_STATE_SYNCING_GROUP": "A sincronizar com o grupo ({time})....",
"BTN_STATE_SYNCING": "A sincronizar...",
"BTN_STATE_SYNCED": "✅ Sincronizado!",
"NOTIF_PLAY": "iniciou a reprodução",
"NOTIF_PAUSE": "pausou a reprodução",
"NOTIF_SEEK": "avançou/recuou no vídeo",
"NOTIF_FORCE_PREPARE": "iniciou a sincronização forçada",
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
"DEBUG_NO_TAB": "Nenhum separador de destino selecionado.",
"DEBUG_COMM_FAIL": "Não foi possível comunicar com o vídeo do separador.",
"EMPTY_PEERS_TITLE": "Nenhum participante ainda",
"EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar",
"EMPTY_HISTORY_TITLE": "Nenhuma atividade ainda",
"EMPTY_HISTORY_HINT": "Reproduza, pause ou avance para ver o histórico",
"EMPTY_LOGS_TITLE": "Nenhum registo",
"EMPTY_LOGS_HINT": "Os eventi de ligação vão aparecer aqui",
"EMPTY_ROOMS_TITLE": "Nenhuma sala ativa",
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize para encontrar salas públicas",
"LABEL_YOU": "Tu",
"ONBOARDING_DONE": "Concluído!",
"LABEL_LOBBY_PEER_READY": "Pronto",
"LABEL_LOBBY_PEER_LOADING": "A carregar...",
"LABEL_PASSWORD_PROTECTED": "Protegido por Palavra-passe",
"LABEL_PEERS_COUNT": "{count} participantes",
"LABEL_CUSTOM_SERVER": "Servidor Personalizado",
"BTN_STATE_CREATING": "🚀 A Criar Sala...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronização do Episódio Falhou",
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Pode ter de sincronizar manualmente.",
"LOBBY_CANCEL_TIMEOUT": "Timeout",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (recuperado)",
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — nem todos os participantes carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo utilizador",
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
"FOOTER_SUPPORT": "☕ Oferece um café",
"FOOTER_REVIEW": "★ Avaliar",
"FOOTER_SUPPORT_PROMPT": "Gostas do KoalaSync? Deixa uma avaliação!",
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio como compressão à reprodução de vídeo",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
"AUDIO_BACK": "← Voltar",
"AUDIO_PAGE_TITLE": "Configurações de áudio",
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ativado",
"AUDIO_PRESET": "Predefinição",
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
"AUDIO_PRESET_DYNAMIC_RANGE": "Gama dinâmica",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Melhoria vocal",
"AUDIO_PRESET_SMOOTH": "Suave",
"AUDIO_PRESET_CUSTOM": "Personalizado",
"AUDIO_PARAM_THRESHOLD": "Limiar",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizador",
"AUDIO_COMING_SOON": "Em breve",
"BTN_RESTART_TOUR": "Reiniciar tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial de introdução",
"HINT_SELECT_VIDEO": "Selecione o seu vídeo aqui!"
}
+30 -1
View File
@@ -91,6 +91,7 @@
"BTN_REGEN_ID": "Сбросить Peer ID",
"BTN_REGEN_ID_TOOLTIP": "Сбросить ваш внутренний идентификатор и переподключиться",
"REGEN_ID_DESC": "Используйте при появлении ошибок дублирования идентификации.",
"REGEN_ID_OTHER_ISSUE": "Другая проблема? Откройте Issue на GitHub",
"LABEL_CONN_STATUS": "Статус подключения",
"LABEL_CONN_STATUS_TOOLTIP": "Текущий статус WebSocket-соединения с сервером",
"CONN_STATUS_DISCONNECTED": "Отключено",
@@ -179,5 +180,33 @@
"LOBBY_CANCEL_PEERS_LEFT": "Все остальные участники вышли",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
"LOBBY_CANCEL_USER": "Отменено пользователем",
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync"
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync",
"FOOTER_SUPPORT": "☕ Угости кофе",
"FOOTER_REVIEW": "★ Оценить",
"FOOTER_SUPPORT_PROMPT": "Нравится KoalaSync? Оставьте отзыв!",
"LABEL_AUDIO_PROCESSING": "Обработка звука",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Применяет аудиоэффекты, такие как сжатие, к воспроизведению видео",
"AUDIO_OPEN_SETTINGS": "Открыть",
"NEW_FEATURE_AUDIO": "Новое: Обработка звука — попробуйте компрессор!",
"AUDIO_BACK": "← Назад",
"AUDIO_PAGE_TITLE": "Настройки звука",
"AUDIO_MASTER_TOGGLE": "Обработка звука",
"AUDIO_COMPRESSOR": "Компрессор",
"AUDIO_COMPRESSOR_ENABLE": "Включено",
"AUDIO_PRESET": "Пресет",
"AUDIO_PRESET_RECOMMENDED": "Рекомендуемый",
"AUDIO_PRESET_DYNAMIC_RANGE": "Динамический диапазон",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Улучшение голоса",
"AUDIO_PRESET_SMOOTH": "Плавный",
"AUDIO_PRESET_CUSTOM": "Пользовательский",
"AUDIO_PARAM_THRESHOLD": "Порог",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Эквалайзер",
"AUDIO_COMING_SOON": "Скоро",
"BTN_RESTART_TOUR": "Перезапустить обучение",
"BTN_RESTART_TOUR_TOOLTIP": "Запустить приветственное руководство заново",
"HINT_SELECT_VIDEO": "Выберите ваше видео здесь!"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "tr",
"HTML_CLASS": "lang-tr",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Oda",
"TAB_ROOM_TOOLTIP": "Oda ayarları ve bağlantı",
"TAB_SYNC": "Senkronizasyon",
"TAB_SYNC_TOOLTIP": "Video senkronizasyon kontrolleri ve uzaktan işlemler",
"TAB_SETTINGS": "Ayarlar",
"TAB_SETTINGS_TOOLTIP": "Eklenti tercihleri",
"TAB_STATUS": "Durum",
"TAB_STATUS_TOOLTIP": "Gelişmiş Teşhis ve Günlükler",
"BTN_CREATE_ROOM": "+ Yeni Oda Oluştur",
"MANUAL_CONNECT_HEADER": "Manuel Bağlantı / Gelişmiş",
"LABEL_SERVER": "Sunucu",
"BTN_SERVER_OFFICIAL": "Resmi",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Resmi güvenilir sunucuyu kullanın",
"BTN_SERVER_CUSTOM": "Özel",
"BTN_SERVER_CUSTOM_TOOLTIP": "Kendi barındırdığınız sunucuya bağlanın",
"PLACEHOLDER_SERVER_URL": "wss://sunucunuz:3000",
"LABEL_ROOM_ID": "Oda Kimliği",
"LABEL_ROOM_ID_TOOLTIP": "Senkronizasyon odanız için benzersiz kimlik",
"PLACEHOLDER_ROOM_ID": "Oda Kimliğini Girin",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Katılmak istediğiniz odanın benzersiz kimliği",
"LABEL_PASSWORD": "Şifre (İsteğe Bağlı)",
"LABEL_PASSWORD_TOOLTIP": "Oda erişimini kısıtlamak için isteğe bağlı şifre",
"PLACEHOLDER_PASSWORD": "Oda Şifresi (isteğe bağlı)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Oda şifresi (yoksa boş bırakın)",
"BTN_JOIN_ROOM": "Odaya Katıl / Oluştur",
"BTN_JOIN_ROOM_TOOLTIP": "Odaya bağlan",
"LABEL_PUBLIC_ROOMS": "Açık Odalar",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Bu sunucuda herkese açık odaların listesi",
"BTN_REFRESH": "YENİLE",
"BTN_REFRESH_TOOLTIP": "Genel odaların listesini yenile",
"PUBLIC_ROOMS_REFRESHING": "Yenileniyor...",
"BTN_REFRESH_COOLDOWN": "{seconds}sn BEKLE",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Oda listesi yenileme soğuyor. {seconds}sn sonra tekrar deneyin.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Açık odalar yenileniyor. Bir sonraki yenileme {seconds}sn sonra yapılabilecek.",
"LABEL_ACTIVE_ROOM": "Aktif Oda",
"LABEL_ACTIVE_ROOM_TOOLTIP": "Şu anda bağlı olduğunuz oda",
"ACTIVE_ROOM_NONE": "YOK",
"ACTIVE_SERVER_OFFICIAL": "Resmi Sunucu",
"LABEL_INVITE_LINK": "Davet Linki",
"LABEL_INVITE_LINK_TOOLTIP": "Katılabilmeleri için bu bağlantıyı arkadaşlarınızla paylaşın",
"LABEL_PEERS_IN_ROOM": "Odadaki Kişiler",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Şu anda bu odaya bağlı olan diğer kullanıcılar",
"NO_PEERS_CONNECTED": "Bağlı kimse yok",
"BTN_LEAVE_ROOM": "Odadan Ayrıl",
"LABEL_SELECT_VIDEO": "Video Seç",
"LABEL_SELECT_VIDEO_TOOLTIP": "Senkronize edilecek videoyu içeren tarayıcı sekmesini seçin",
"OPTION_SELECT_TAB": "-- Bir Sekme Seçin --",
"LABEL_REMOTE_CONTROL": "Uzaktan Kumanda",
"BTN_COPY_INVITE": "📋 Davet Linki",
"BTN_COPY_INVITE_TOOLTIP": "Davet Linkini Kopyala",
"BTN_PLAY": "▶ Oynat",
"BTN_PLAY_TOOLTIP": "Herkese Oynat komutu gönder",
"BTN_PAUSE": "⏸ Duraklat",
"BTN_PAUSE_TOOLTIP": "Herkese Duraklat komutu gönder",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Tüm kullanıcıları senkronize etmeye zorla",
"OPTION_JUMP_TO_OTHERS": "Diğerlerine Atla",
"OPTION_JUMP_TO_ME": "Bana Atla",
"OPTION_JUMP_MODE_TOOLTIP": "Senkronizasyon hedefini seçin",
"LABEL_LAST_ACTIVITY": "Son Etkinlik Durumu",
"LABEL_LAST_ACTIVITY_TOOLTIP": "En son oynatma, duraklatma veya arama komutunu gösterir",
"NO_RECENT_COMMANDS": "Son komut yok",
"LOBBY_HEADER": "BÖLÜM LOBİSİ",
"LOBBY_WAITING_FOR": "🎬 Beklenen: \"{title}\"",
"LOBBY_WAITING_PEERS": "Bağlantılar bekleniyor...",
"BTN_SKIP_PLAY": "Atla ve Yine de Oynat",
"BTN_SKIP_PLAY_TOOLTIP": "Lobiyi iptal et ve yine de oynat",
"LOBBY_CONNECT_FIRST": "Önce bir odaya bağlanın",
"LOBBY_CONNECT_FIRST_DESC": "Videoları senkronize etmek için bir davet bağlantısı aracılığıyla bir odaya katılmanız veya yeni bir oda oluşturmanız gerekir.",
"BTN_CREATE_ROOM_ALT": "Yeni Oda Oluştur",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Yeni rastgele bir oda oluşturun ve katılın",
"LABEL_USERNAME": "Kullanıcı Adınız",
"LABEL_USERNAME_TOOLTIP": "Kullanıcı adı başkalarının sizi tanımasına yardımcı olur.",
"PLACEHOLDER_USERNAME": "Anonim Koala",
"LABEL_HIDE_CLUTTER": "Gereksiz Sekmeleri Gizle",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Listeyi temiz tutmak için video olmayan sekmeleri ve alakasız alan adlarını filtreler",
"LABEL_AUTO_SYNC_NEXT": "Sonraki Bölümü Otomatik Eşitle",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Bir bölüm değiştiğinde otomatik olarak duraklatılır ve tüm bağlantıların hazır olmasını bekler, ardından birlikte senkronize olarak başlar.",
"LABEL_AUTO_COPY_INVITE": "Davet Linkini Otomatik Kopyala",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Yeni bir oda oluşturulduğunda davet bağlantısını panoya otomatik olarak kopyalar.",
"LABEL_NOTIFICATIONS": "Tarayıcı Bildirimleri",
"LABEL_NOTIFICATIONS_TOOLTIP": "Biri katıldığında/ayrıldığında veya oynattığında/duraklattığında yerel sistem bildirimlerini gösterir.",
"LABEL_LANGUAGE": "Uygulama Dili",
"LABEL_LANGUAGE_TOOLTIP": "Tercih ettiğiniz eklenti dilini seçin",
"LABEL_TROUBLESHOOTING": "Sorun Giderme",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Bağlantı sorunlarını giderme araçları",
"BTN_REGEN_ID": "Bağlantı Kimliğini Yeniden Oluştur",
"BTN_REGEN_ID_TOOLTIP": "Dahili kimliğinizi yeniden oluşturun ve tekrar bağlanın",
"REGEN_ID_DESC": "\"Duplicate Identity\" (Mükerrer Kimlik) hataları görüyorsanız bunu kullanın.",
"REGEN_ID_OTHER_ISSUE": "Başka bir sorun? GitHub Issue açın",
"LABEL_CONN_STATUS": "Bağlantı Durumu",
"LABEL_CONN_STATUS_TOOLTIP": "Mevcut WebSocket bağlantı durumu",
"CONN_STATUS_DISCONNECTED": "Bağlantı Kesildi",
"BTN_RETRY": "TEKRAR DENE",
"BTN_RETRY_TOOLTIP": "Sunucuya yeniden bağlanmayı dene",
"BTN_COPY_LOGS": "Günlükleri Kopyala",
"BTN_COPY_LOGS_TOOLTIP": "Paylaşmak için günlükleri panoya kopyalayın",
"LABEL_VIDEO_DEBUG": "Video Hata Ayıklama Bilgisi",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Şu anda seçili video öğesi hakkında teknik ayrıntılar",
"VIDEO_DEBUG_EMPTY": "Sekme seçilmedi veya video algılanmadı.",
"LABEL_HISTORY": "Tam İşlem Geçmişi",
"LABEL_HISTORY_TOOLTIP": "Odadaki tüm senkronizasyon komutlarının kronolojik günlüğü",
"HISTORY_EMPTY": "Henüz etkinlik yok",
"LABEL_LOGS": "Günlükler (Son 50)",
"LABEL_LOGS_TOOLTIP": "Hata ayıklama için teknik bağlantı günlükleri",
"BTN_CLEAR": "TEMİZLE",
"BTN_CLEAR_TOOLTIP": "Günlük çıktısını temizle",
"LABEL_GITHUB": "GitHub Deposu",
"BTN_ONBOARDING_SKIP": "Atla",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Eğitimi atla",
"BTN_ONBOARDING_NEXT": "İleri",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Bir sonraki adıma git",
"ONBOARDING_1_TITLE": "KoalaSync'e Hoş Geldiniz!",
"ONBOARDING_1_TEXT": "Nerede olursanız olun videoları mükemmel bir senkronizasyonla birlikte izleyin. Hızlı bir tura çıkalım!",
"ONBOARDING_2_TITLE": "1. Oda Oluştur",
"ONBOARDING_2_TEXT": "Buradan başlayın. Bir oda oluşturun ve davet bağlantısını arkadaşlarınızla paylaşın.",
"ONBOARDING_3_TITLE": "2. Video Seç",
"ONBOARDING_3_TEXT": "Senkronize etmek istediğiniz videoyu seçmek için buraya gidin. Oynatın, duraklatın ve arayın — herkes senkronize kalır.",
"ONBOARDING_4_TITLE": "3. Kişiselleştir",
"ONBOARDING_4_TEXT": "Arkadaşlarınızın kim olduğunuzu bilmesi için eğlenceli bir kullanıcı adı seçin.",
"ONBOARDING_5_TITLE": "Her şey hazır!",
"ONBOARDING_5_TEXT": "Patlamış mısır alma zamanı. Birlikte izlemenin keyfini çıkarın!",
"ERR_CONN_TIMEOUT": "Bağlantı zaman aşımına uğradı. Lütfen tekrar deneyin.",
"ERR_INVALID_SERVER_URL": "Geçersiz Sunucu URL formatı.",
"ERR_IDENTITY_NOT_LOADED": "Kimlik henüz yüklenmedi. Biraz bekleyin ve tekrar deneyin.",
"ERR_NO_PEERS_TIME": "Bilinen bir zamanı olan başka bağlantı yok. 'Bana Atla' seçeneğine geçin.",
"ERR_NO_VIDEO_TAB": "Video sekmesine bağlanılamadı.",
"ERR_SELECT_VIDEO": "Lütfen önce bir video seçin!",
"TOAST_INVITE_COPIED": "Davet bağlantısı kopyalandı!",
"TOAST_COPY_FAILED": "Panoya kopyalanamadı",
"TOAST_LOBBY_SKIPPED": "Bölüm Lobisi atlandı.",
"TOAST_LOBBY_SKIP_FAILED": "Lobi atlanamadı.",
"TOAST_LOGS_COPIED": "Kopyalandı!",
"TOAST_PEER_JOINED": "{name} odaya katıldı",
"TOAST_PEER_LEFT": "{name} odadan ayrıldı",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Bağlandı",
"STATUS_RECONNECTING": "Yeniden bağlanılıyor...",
"STATUS_CONNECTING": "Bağlanılıyor...",
"STATUS_FAILED": "Başarısız",
"STATUS_DISCONNECTED": "Bağlantı Kesildi",
"BTN_STATE_JOINING": "🚀 Katılınıyor...",
"BTN_STATE_RECONNECTING": "🔄 Yeniden bağlanılıyor...",
"BTN_STATE_PLAYING": "▶ Oynatılıyor...",
"BTN_STATE_PAUSING": "⏸ Duraklatılıyor...",
"BTN_STATE_SYNCING_GROUP": "Gruba eşitleniyor ({time})...",
"BTN_STATE_SYNCING": "Senkronize ediliyor...",
"BTN_STATE_SYNCED": "✅ Eşitlendi!",
"NOTIF_PLAY": "oynatmayı başlattı",
"NOTIF_PAUSE": "oynatmayı duraklattı",
"NOTIF_SEEK": "videoda arama yaptı",
"NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı",
"NOTIF_FORCE_EXECUTE": "herkesi eşitledi",
"DEBUG_NO_TAB": "Hedef sekme seçilmedi.",
"DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.",
"EMPTY_PEERS_TITLE": "Henüz kimse yok",
"EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın",
"EMPTY_HISTORY_TITLE": "Henüz etkinlik yok",
"EMPTY_HISTORY_HINT": "Geçmişi görmek için oynatın, duraklatın veya arayın",
"EMPTY_LOGS_TITLE": "Günlük yok",
"EMPTY_LOGS_HINT": "Bağlantı olayları burada görünecektir",
"EMPTY_ROOMS_TITLE": "Aktif oda yok",
"EMPTY_ROOMS_HINT": "Bir oda oluşturun veya genel odaları bulmak için yenileyin",
"LABEL_YOU": "Siz",
"ONBOARDING_DONE": "Tamamlandı!",
"LABEL_LOBBY_PEER_READY": "Hazır",
"LABEL_LOBBY_PEER_LOADING": "Yükleniyor...",
"LABEL_PASSWORD_PROTECTED": "Şifre Korumalı",
"LABEL_PEERS_COUNT": "{count} kişi",
"LABEL_CUSTOM_SERVER": "Özel Sunucu",
"BTN_STATE_CREATING": "🚀 Oda Oluşturuluyor...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Bölüm Eşitlemesi Başarısız",
"NOTIF_LOBBY_CANCEL_MSG": "Otomatik eşitleme iptal edildi: {reason}. Manuel olarak eşitlemeniz gerekebilir.",
"LOBBY_CANCEL_TIMEOUT": "Zaman aşımı",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Zaman aşımı (kurtarıldı)",
"LOBBY_CANCEL_PEERS_LEFT": "Diğer tüm bağlantılar ayrıldı",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zaman aşımı — tüm bağlantılar bölümü yüklemedi",
"LOBBY_CANCEL_USER": "Kullanıcı tarafından iptal edildi",
"NOTIF_ERROR_TITLE": "KoalaSync Hatası",
"FOOTER_SUPPORT": "☕ Bana kahve ısmarla",
"FOOTER_REVIEW": "★ Değerlendir",
"FOOTER_SUPPORT_PROMPT": "KoalaSync'i beğendin mi? Bir yorum bırak!",
"LABEL_AUDIO_PROCESSING": "Ses işleme",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Video oynatımına sıkıştırma gibi ses efektleri uygular",
"AUDIO_OPEN_SETTINGS": "Aç",
"NEW_FEATURE_AUDIO": "Yeni: Ses işleme — kompresörü deneyin!",
"AUDIO_BACK": "← Geri",
"AUDIO_PAGE_TITLE": "Ses ayarları",
"AUDIO_MASTER_TOGGLE": "Ses işleme",
"AUDIO_COMPRESSOR": "Kompresör",
"AUDIO_COMPRESSOR_ENABLE": "Etkin",
"AUDIO_PRESET": "Ön ayar",
"AUDIO_PRESET_RECOMMENDED": "Önerilen",
"AUDIO_PRESET_DYNAMIC_RANGE": "Dinamik aralık",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Ses iyileştirme",
"AUDIO_PRESET_SMOOTH": "Yumuşak",
"AUDIO_PRESET_CUSTOM": "Özel",
"AUDIO_PARAM_THRESHOLD": "Eşik",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Ekolayzer",
"AUDIO_COMING_SOON": "Çok yakında",
"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!"
}
+2 -1
View File
@@ -1,7 +1,8 @@
{
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.0.5",
"version": "2.2.4",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
+142 -27
View File
@@ -4,6 +4,13 @@
<meta charset="utf-8">
<title>KoalaSync</title>
<style>
@font-face {
font-family: 'Twemoji Country Flags';
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
src: url('assets/TwemojiCountryFlags.woff2') format('woff2');
font-display: swap;
}
:root {
--bg: #0f172a;
--card: #1e293b;
@@ -23,7 +30,7 @@
padding: 16px;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
}
@@ -127,7 +134,7 @@
font-weight: 700;
}
input, select {
input, select, option {
width: 100%;
box-sizing: border-box;
background: var(--card);
@@ -139,6 +146,20 @@
font-family: inherit;
}
@supports (appearance: base-select) {
#langSelector,
#langSelector::picker(select) {
appearance: base-select;
}
#langSelector::picker(select) {
background-color: var(--bg);
border: 1px solid #334155;
border-radius: 8px;
padding: 4px;
font-family: 'Twemoji Country Flags', inherit;
}
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
@@ -310,6 +331,47 @@
transform: translateX(16px);
background-color: white;
}
.popup-footer {
margin-top: 20px;
text-align: center;
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 10px;
color: var(--text-muted);
font-size: 10px;
opacity: 0.6;
}
.popup-footer a {
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s, opacity 0.2s;
}
.popup-footer a:hover {
color: var(--accent);
opacity: 1;
}
.feature-hint {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-left: 4px;
animation: featurePulse 2s ease-in-out infinite;
cursor: help;
vertical-align: middle;
}
@keyframes featurePulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
@keyframes spotlightPulse {
0%, 100% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 0px var(--accent); }
50% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 6px var(--accent); }
}
@keyframes floatHint {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
</style>
</head>
<body>
@@ -375,9 +437,9 @@
<div class="info-card" style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; border-left: 4px solid var(--accent);">
<div>
<label style="margin-bottom: 0;" title="The room you are currently connected to" data-i18n="LABEL_ACTIVE_ROOM" data-i18n-title="LABEL_ACTIVE_ROOM_TOOLTIP">Active Room</label>
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;" data-i18n="ACTIVE_ROOM_NONE">NONE</div>
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;">NONE</div>
</div>
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" data-i18n="ACTIVE_SERVER_OFFICIAL">Official Server</div>
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Official Server</div>
</div>
<div class="info-card" style="margin-bottom: 20px;">
@@ -403,11 +465,16 @@
<div id="tab-sync" class="tab-content">
<!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active">
<div class="form-group">
<div class="form-group" style="position: relative;">
<label title="Choose the browser tab containing the video to sync" data-i18n="LABEL_SELECT_VIDEO" data-i18n-title="LABEL_SELECT_VIDEO_TOOLTIP">Select Video</label>
<select id="targetTab">
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
</select>
<!-- Hint Tooltip -->
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: white; padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
Select your video here!
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
@@ -495,22 +562,47 @@
</label>
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<div style="display: flex; align-items: center; gap: 8px;">
<label id="audioProcessingLabel" style="margin-bottom: 0; cursor: help; white-space: nowrap; font-size: 13px;" data-i18n="LABEL_AUDIO_PROCESSING" data-i18n-title="LABEL_AUDIO_PROCESSING_TOOLTIP">Audio Processing</label>
</div>
<a id="audioSettingsLink" href="#" style="font-size: 11px; color: var(--accent); text-decoration: none; white-space: nowrap; background: rgba(99,102,241,0.12); padding: 5px 12px; border-radius: 6px; font-weight: 600; transition: background 0.2s;" data-i18n="AUDIO_OPEN_SETTINGS">Open</a>
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;" data-i18n="LABEL_LANGUAGE" data-i18n-title="LABEL_LANGUAGE_TOOLTIP" title="Choose your preferred extension language">App Language</label>
<select id="langSelector" style="width: 150px; background: var(--bg); border: 1px solid #334155; color: white; padding: 6px 10px; border-radius: 8px; font-size: 13px; cursor: pointer; outline: none; font-family: inherit;">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="pt-BR">Português (Brasil)</option>
<option value="ru">Русский</option>
<select id="langSelector" style="width: 165px; background: var(--bg); border: 1px solid #334155; color: white; padding: 6px 10px; border-radius: 8px; font-size: 13px; cursor: pointer; outline: none; font-family: inherit;">
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
<option value="es">🇪🇸 Español</option>
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
<option value="ru">🇷🇺 Русский</option>
<option value="it">🇮🇹 Italiano</option>
<option value="pl">🇵🇱 Polski</option>
<option value="tr">🇹🇷 Türkçe</option>
<option value="nl">🇳🇱 Nederlands</option>
<option value="ja">🇯🇵 日本語</option>
<option value="ko">🇰🇷 한국어</option>
<option value="pt">🇵🇹 Português (Portugal)</option>
</select>
</div>
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
<label title="Tools for fixing connection issues" data-i18n="LABEL_TROUBLESHOOTING" data-i18n-title="LABEL_TROUBLESHOOTING_TOOLTIP">Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
<button id="restartTourBtn" class="secondary" style="width: 100%; font-size: 11px;" title="Restart the onboarding tutorial" data-i18n="BTN_RESTART_TOUR" data-i18n-title="BTN_RESTART_TOUR_TOOLTIP">Restart Tutorial</button>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px; margin-top: 8px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;" data-i18n="REGEN_ID_DESC">Use this if you see "Duplicate Identity" errors.</p>
<p style="font-size: 9px; text-align: center; margin-top: 6px;"><a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" style="color: var(--accent); text-decoration: none;" data-i18n="REGEN_ID_OTHER_ISSUE">Other issue? Open a GitHub Issue</a></p>
</div>
<div class="popup-footer">
<div style="font-size: 9px; color: var(--text-muted); opacity: 0.6; margin-bottom: 6px; line-height: 1.3;" data-i18n="FOOTER_SUPPORT_PROMPT">Enjoying KoalaSync? Leave a review!</div>
<a id="settingsReviewLink" href="#" target="_blank" data-i18n="FOOTER_REVIEW">★ Rate us</a>
<span> · </span>
<a id="settingsSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">☕ Buy me a coffee</a>
<span> · </span>
<span id="settingsVersion">v0.0.0</span>
</div>
</div>
@@ -519,13 +611,14 @@
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
<span id="connDot" class="status-dot status-offline"></span>
<span id="connText" style="flex:1;" data-i18n="CONN_STATUS_DISCONNECTED">Disconnected</span>
<span id="connText" style="flex:1;">Disconnected</span>
<span id="connPing" style="font-size:11px; font-family:monospace; font-weight:600; opacity:0.8;"></span>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px; margin: 0;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
</div>
<label title="Technical details about the currently selected video element" data-i18n="LABEL_VIDEO_DEBUG" data-i18n-title="LABEL_VIDEO_DEBUG_TOOLTIP">Video Debug Info</label>
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;" data-i18n="VIDEO_DEBUG_EMPTY">
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;">
No tab selected or video detected.
</div>
@@ -542,25 +635,47 @@
</div>
<div id="logList"></div>
<div style="margin-top: 20px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px;">
<div class="popup-footer">
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;" data-i18n="LABEL_GITHUB">GitHub Repository</a>
<div id="appVersion" style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v0.0.0</div>
<div style="margin-top: 4px;">
<div style="font-size: 9px; color: var(--text-muted); opacity: 0.6; margin-bottom: 6px; line-height: 1.3;" data-i18n="FOOTER_SUPPORT_PROMPT">Enjoying KoalaSync? Leave a review!</div>
<a id="devReviewLink" href="#" target="_blank" data-i18n="FOOTER_REVIEW">★ Rate us</a>
<span> · </span>
<a id="devSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">☕ Buy me a coffee</a>
<span> · </span>
<span id="appVersion">v0.0.0</span>
</div>
</div>
</div>
<script src="popup.js" type="module"></script>
<!-- Onboarding Overlay -->
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter: blur(2px); z-index:1000; align-items:flex-end; justify-content:center; padding-bottom: 20px;">
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; width:90%; text-align:center; box-shadow:0 8px 32px rgba(0,0,0,0.5); ">
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">👋</div>
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 8px; font-size:16px;">Welcome to KoalaSync!</h2>
<p id="onboarding-text" style="color:var(--text-muted); font-size:13px; margin:0 0 16px; line-height:1.4;">Let's get you started.</p>
<div style="display:flex; gap:8px; justify-content:center;">
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:transparent; z-index:1000; pointer-events:auto;">
<!-- Spotlight element -->
<div id="onboarding-spotlight" style="position:fixed; pointer-events:none; border:2px solid var(--accent); box-shadow:0 0 0 9999px rgba(15, 23, 42, 0.65); border-radius:8px; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display:none; opacity:0; z-index:1001; animation: spotlightPulse 2s infinite ease-in-out;"></div>
<!-- Onboarding Card -->
<div id="onboarding-card" style="position:fixed; background:var(--card); padding:16px 20px; border-radius:12px; width:280px; box-shadow:0 8px 32px rgba(0,0,0,0.5); z-index:1002; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-sizing:border-box; border:1px solid #334155; opacity:0; transform: scale(0.9); display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span id="onboarding-icon" style="font-size:24px;">👋</span>
<span id="onboarding-step-indicator" style="font-size:10px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:0.5px;">Step 1 of 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>
+432 -71
View File
@@ -1,7 +1,7 @@
import { EVENTS, OFFICIAL_LANDING_PAGE_URL } from './shared/constants.js';
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage } from './i18n.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
const elements = {
@@ -16,6 +16,7 @@ const elements = {
clearLogs: document.getElementById('clearLogs'),
connDot: document.getElementById('connDot'),
connText: document.getElementById('connText'),
connPing: document.getElementById('connPing'),
serverUrl: document.getElementById('serverUrl'),
serverOfficial: document.getElementById('serverOfficial'),
serverCustom: document.getElementById('serverCustom'),
@@ -28,6 +29,7 @@ const elements = {
inviteLink: document.getElementById('inviteLink'),
filterNoise: document.getElementById('filterNoise'),
regenId: document.getElementById('regenId'),
restartTourBtn: document.getElementById('restartTourBtn'),
lastActionCard: document.getElementById('lastActionCard'),
historyList: document.getElementById('historyList'),
copyLogs: document.getElementById('copyLogs'),
@@ -50,9 +52,16 @@ const elements = {
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
browserNotifications: document.getElementById('browserNotifications'),
autoCopyInvite: document.getElementById('autoCopyInvite'),
syncTabCopyInvite: document.getElementById('syncTabCopyInvite'),
cancelLobbyBtn: document.getElementById('cancelLobbyBtn'),
langSelector: document.getElementById('langSelector')
langSelector: document.getElementById('langSelector'),
audioSettingsLink: document.getElementById('audioSettingsLink'),
settingsSupportLink: document.getElementById('settingsSupportLink'),
settingsReviewLink: document.getElementById('settingsReviewLink'),
settingsVersion: document.getElementById('settingsVersion'),
devSupportLink: document.getElementById('devSupportLink'),
devReviewLink: document.getElementById('devReviewLink'),
syncTabCopyInvite: document.getElementById('syncTabCopyInvite')
};
let localPeerId = null;
@@ -60,6 +69,7 @@ let lastPeersJson = null;
let lastKnownPeers = [];
let isDevTabVisible = false;
let reconnectSlowMode = false;
let isProcessingConnection = false;
let joinBtnTimeout = null;
let forceSyncResetTimer = null;
let popupIntervals = [];
@@ -71,8 +81,65 @@ let pendingConnectionErrorMsg = null;
let roomListRefreshTimer = null;
let roomListRefreshInterval = null;
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
const FEATURE_HINTS = [
{
key: 'audio_processing',
selector: '#audioProcessingLabel',
position: 'beforebegin',
i18nTooltip: 'NEW_FEATURE_AUDIO'
}
];
// --- Helpers ---
function configureFooterLinks() {
const reviewUrl = getReviewUrl();
[elements.settingsSupportLink, elements.devSupportLink].forEach(link => {
if (link) link.href = SUPPORT_URL;
});
[elements.settingsReviewLink, elements.devReviewLink].forEach(link => {
if (link) link.href = reviewUrl;
});
}
function openAudioSettingsPage() {
chrome.tabs.create({ url: chrome.runtime.getURL('audio-options.html') });
}
async function updateFeatureHints() {
const data = await chrome.storage.sync.get(['dismissedHints']);
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
FEATURE_HINTS.forEach(feature => {
const target = document.querySelector(feature.selector);
if (!target) return;
const existing = target.parentElement?.querySelector(`.feature-hint[data-feature="${feature.key}"]`);
if (dismissed.includes(feature.key)) {
if (existing) existing.remove();
return;
}
if (!existing) {
const hint = document.createElement('span');
hint.className = 'feature-hint';
hint.dataset.feature = feature.key;
hint.title = getMessage(feature.i18nTooltip);
target.insertAdjacentElement(feature.position || 'afterend', hint);
} else {
existing.title = getMessage(feature.i18nTooltip);
}
});
}
async function dismissFeatureHint(featureKey) {
const data = await chrome.storage.sync.get(['dismissedHints']);
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
if (!dismissed.includes(featureKey)) {
await chrome.storage.sync.set({ dismissedHints: [...dismissed, featureKey] });
}
await updateFeatureHints();
}
function clearConnectionErrorTimer() {
if (connectionErrorTimer) {
clearTimeout(connectionErrorTimer);
@@ -108,14 +175,24 @@ function setRoomRefreshCooldown() {
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale']);
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
// Migrate preferences from sync → local for existing users
const oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
const toMigrate = {};
for (const key of ['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']) {
if (localData[key] === undefined && oldSync[key] !== undefined) {
toMigrate[key] = oldSync[key];
localData[key] = oldSync[key];
}
}
if (Object.keys(toMigrate).length) {
await chrome.storage.local.set(toMigrate);
}
let activeLang = data.locale;
let activeLang = localData.locale;
if (!activeLang) {
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
chrome.storage.sync.set({ locale: activeLang });
activeLang = getSystemLanguage();
chrome.storage.local.set({ locale: activeLang });
}
await loadLocale(activeLang);
@@ -123,21 +200,21 @@ async function init() {
if (elements.langSelector) elements.langSelector.value = activeLang;
let username = data.username;
let username = localData.username;
if (!username) {
username = generateUsername();
chrome.storage.sync.set({ username });
await chrome.storage.local.set({ username });
}
elements.serverUrl.value = data.serverUrl || '';
elements.roomId.value = data.roomId || '';
elements.password.value = data.password || '';
elements.serverUrl.value = localData.serverUrl || '';
elements.roomId.value = localData.roomId || '';
elements.password.value = localData.password || '';
elements.username.value = username;
if (elements.filterNoise) elements.filterNoise.checked = data.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
if (elements.forceSyncMode) elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
if (elements.browserNotifications) elements.browserNotifications.checked = data.browserNotifications === true;
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = data.autoCopyInvite !== false;
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
if (elements.forceSyncMode) elements.forceSyncMode.value = localData.forceSyncMode || 'jump-to-others';
if (elements.browserNotifications) elements.browserNotifications.checked = localData.browserNotifications === true;
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = localData.autoCopyInvite !== false;
// Set Version Info
const versionTxt = `v${chrome.runtime.getManifest().version}`;
@@ -145,18 +222,24 @@ async function init() {
if (versionEl) versionEl.textContent = versionTxt;
const popupVerEl = document.getElementById('popupVersion');
if (popupVerEl) popupVerEl.textContent = versionTxt;
if (elements.settingsVersion) elements.settingsVersion.textContent = versionTxt;
configureFooterLinks();
await updateFeatureHints();
if (data.useCustomServer) {
if (localData.useCustomServer) {
setServerMode(true);
} else {
setServerMode(false);
}
toggleUIState(!!data.roomId);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
toggleUIState(!!localData.roomId);
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl);
refreshLogs();
refreshHistory();
// Default connection status (localized) before async check
applyConnectionStatus('disconnected');
// Initial Status Check
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
if (chrome.runtime.lastError) {
@@ -168,6 +251,7 @@ async function init() {
localPeerId = res.peerId;
reconnectSlowMode = res.reconnectSlowMode || false;
applyConnectionStatus(res.status);
updatePingDisplay(res.ping);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
@@ -177,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();
}
}
});
@@ -571,7 +668,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const token = {};
populateTabsToken = token;
const data = await chrome.storage.sync.get(['filterNoise']);
const data = await chrome.storage.local.get(['filterNoise']);
const isFilterActive = data.filterNoise !== false;
let currentTargetTabId = providedTargetTabId;
@@ -732,6 +829,9 @@ function applyConnectionStatus(status) {
if (elements.connText) {
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : getMessage('STATUS_DISCONNECTED')));
}
if (!connected) {
updatePingDisplay(null);
}
if (elements.retryBtn) {
elements.retryBtn.style.display = reconnecting && reconnectSlowMode ? 'block' : 'none';
}
@@ -751,6 +851,23 @@ function applyConnectionStatus(status) {
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = getMessage('BTN_SYNC');
}
function updatePingDisplay(pingMs) {
if (!elements.connPing) return;
if (pingMs === null || pingMs === undefined || typeof pingMs !== 'number' || !Number.isFinite(pingMs)) {
elements.connPing.textContent = '';
elements.connPing.style.color = '';
return;
}
elements.connPing.textContent = `${Math.round(pingMs)}ms`;
if (pingMs < 50) {
elements.connPing.style.color = '#22c55e';
} else if (pingMs < 150) {
elements.connPing.style.color = '#f59e0b';
} else {
elements.connPing.style.color = '#ef4444';
}
}
function updateHistory(history) {
if (!history || !elements.historyList) return;
elements.historyList.innerHTML = '';
@@ -879,7 +996,7 @@ function checkInviteLink() {
if (serverUrl || useCustomServer) {
elements.serverUrl.value = serverUrl;
setServerMode(useCustomServer);
chrome.storage.sync.set({ serverUrl, useCustomServer });
chrome.storage.local.set({ serverUrl, useCustomServer });
}
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
@@ -896,9 +1013,9 @@ function setServerMode(custom) {
elements.serverOfficial.classList.toggle('active', !custom);
elements.serverCustom.classList.toggle('active', custom);
elements.serverUrl.style.display = custom ? 'block' : 'none';
chrome.storage.sync.get(['useCustomServer', 'serverUrl'], (data) => {
chrome.storage.local.get(['useCustomServer', 'serverUrl'], (data) => {
if (data.useCustomServer !== custom) {
chrome.storage.sync.set({ useCustomServer: custom });
chrome.storage.local.set({ useCustomServer: custom });
if (!custom || data.serverUrl) {
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
}
@@ -910,46 +1027,84 @@ elements.serverOfficial.addEventListener('click', () => setServerMode(false));
elements.serverCustom.addEventListener('click', () => setServerMode(true));
elements.filterNoise.addEventListener('change', () => {
chrome.storage.sync.set({ filterNoise: elements.filterNoise.checked }, () => {
chrome.storage.local.set({ filterNoise: elements.filterNoise.checked }, () => {
populateTabs();
});
});
elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
elements.browserNotifications.addEventListener('change', () => {
chrome.storage.sync.set({ browserNotifications: elements.browserNotifications.checked });
chrome.storage.local.set({ browserNotifications: elements.browserNotifications.checked });
});
if (elements.autoCopyInvite) {
elements.autoCopyInvite.addEventListener('change', () => {
chrome.storage.sync.set({ autoCopyInvite: elements.autoCopyInvite.checked });
chrome.storage.local.set({ autoCopyInvite: elements.autoCopyInvite.checked });
});
}
if (elements.audioSettingsLink) {
elements.audioSettingsLink.addEventListener('click', async (event) => {
event.preventDefault();
await dismissFeatureHint('audio_processing');
openAudioSettingsPage();
});
}
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync' || !changes.audioSettings) return;
});
elements.forceSyncMode.addEventListener('change', () => {
chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value });
chrome.storage.local.set({ forceSyncMode: elements.forceSyncMode.value });
});
elements.serverUrl.addEventListener('input', () => {
chrome.storage.sync.set({ serverUrl: elements.serverUrl.value });
chrome.storage.local.set({ serverUrl: elements.serverUrl.value });
});
elements.username.addEventListener('change', () => {
chrome.storage.sync.set({ username: elements.username.value });
chrome.storage.local.set({ username: elements.username.value });
});
if (elements.langSelector) {
elements.langSelector.addEventListener('change', async () => {
const selectedLang = elements.langSelector.value;
await chrome.storage.sync.set({ locale: selectedLang });
await chrome.storage.local.set({ locale: selectedLang });
await loadLocale(selectedLang);
translateDOM();
configureFooterLinks();
await updateFeatureHints();
// Re-apply connection and room UI state since translateDOM may overwrite dynamic elements
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
if (chrome.runtime.lastError) return;
if (res) {
localPeerId = res.peerId;
reconnectSlowMode = res.reconnectSlowMode || false;
applyConnectionStatus(res.status);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
await populateTabs(res.peers, res.targetTabId);
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
applyConnectionStatus('disconnected');
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
await populateTabs();
}
});
refreshLogs();
refreshHistory();
populateTabs();
});
}
@@ -958,7 +1113,7 @@ elements.serverUrl.addEventListener('change', () => {
if (url && !url.includes('://')) {
url = 'ws://' + url;
elements.serverUrl.value = url;
chrome.storage.sync.set({ serverUrl: url });
chrome.storage.local.set({ serverUrl: url });
}
if (elements.serverCustom.classList.contains('active') && url) {
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
@@ -981,6 +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 });
});
});
@@ -1023,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;
@@ -1036,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);
@@ -1057,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;
}
}
@@ -1072,7 +1236,7 @@ elements.joinBtn.addEventListener('click', async () => {
window.justCreatedRoom = true;
}
await chrome.storage.sync.set({ serverUrl, roomId, password, useCustomServer: useCustom });
await chrome.storage.local.set({ serverUrl, roomId, password, useCustomServer: useCustom });
elements.roomId.value = roomId;
// Tell background to connect
@@ -1083,13 +1247,16 @@ elements.joinBtn.addEventListener('click', async () => {
});
elements.leaveBtn.addEventListener('click', async () => {
if (isProcessingConnection) return;
isProcessingConnection = true;
clearConnectionErrorTimer();
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
await chrome.storage.sync.set({ roomId: '', password: '' });
await chrome.storage.local.set({ roomId: '', password: '' });
elements.roomId.value = '';
elements.password.value = '';
lastKnownPeers = [];
updateUI(null, null);
isProcessingConnection = false;
});
function generateRoomId() {
@@ -1140,6 +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;
@@ -1149,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;
@@ -1158,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 || [];
@@ -1167,6 +1344,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
if (otherTimes.length === 0) {
showError(getMessage('ERR_NO_PEERS_TIME'));
elements.forceSyncBtn.disabled = false;
return;
}
@@ -1175,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;
@@ -1354,6 +1530,7 @@ async function refreshLogs() {
}
chrome.runtime.onMessage.addListener((msg) => {
if (!msg) return;
if (msg.type === 'LOG_UPDATE') {
refreshLogs();
if (msg.log && msg.log.type === 'error') {
@@ -1440,8 +1617,10 @@ chrome.runtime.onMessage.addListener((msg) => {
}
if (msg.status === 'connected') {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.peers) updatePeerList(res.peers);
if (res && res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
if (!res) return;
if (res.peers) updatePeerList(res.peers);
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
updatePingDisplay(res.ping);
});
}
if (msg.status === 'disconnected') {
@@ -1460,11 +1639,14 @@ chrome.runtime.onMessage.addListener((msg) => {
}
});
}
} else if (msg.type === 'PING_UPDATE') {
updatePingDisplay(msg.ping);
} else if (msg.type === 'HISTORY_UPDATE') {
updateHistory(msg.history);
} else if (msg.type === 'ROOM_LIST') {
updateRoomList(msg.rooms);
} else if (msg.type === 'JOIN_STATUS') {
isProcessingConnection = false;
if (joinBtnTimeout) {
clearTimeout(joinBtnTimeout);
joinBtnTimeout = null;
@@ -1474,11 +1656,17 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.success) {
// Final confirmation of join from background
chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
});
} else {
// Join failed: reset UI state
chrome.storage.local.set({ roomId: '', password: '' });
elements.roomId.value = '';
elements.password.value = '';
updateUI(null, null);
}
} else if (msg.type === 'LOBBY_UPDATE') {
@@ -1566,6 +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 : [];
@@ -1629,7 +1818,7 @@ elements.copyLogs.addEventListener('click', () => {
// ── Action History (last 20) ──
lines.push('## Action History (last 20)');
if (history && history.length > 0) {
const recent = history.slice(-20);
const recent = history.slice(0, 20).reverse();
lines.push('```');
for (const h of recent) {
if (!h) continue;
@@ -1648,7 +1837,7 @@ elements.copyLogs.addEventListener('click', () => {
// ── Logs (last 50) ──
lines.push('## Logs (last 50)');
if (logs && logs.length > 0) {
const recent = logs.slice(-50);
const recent = logs.slice(0, 50).reverse();
lines.push('```');
for (const l of recent) {
if (!l) continue;
@@ -1903,36 +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();
@@ -1943,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', () => {
@@ -1984,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();
});
+110 -110
View File
@@ -1,15 +1,15 @@
{
"name": "koalasync",
"version": "2.0.5",
"version": "2.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
"version": "2.0.5",
"version": "2.2.4",
"devDependencies": {
"archiver": "^7.0.1",
"esbuild": "^0.28.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0",
"sharp": "^0.34.5",
@@ -28,9 +28,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -45,9 +45,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -62,9 +62,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -79,9 +79,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -96,9 +96,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -113,9 +113,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -130,9 +130,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -147,9 +147,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -164,9 +164,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -181,9 +181,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -198,9 +198,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -215,9 +215,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -232,9 +232,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -249,9 +249,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -266,9 +266,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -283,9 +283,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -300,9 +300,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -317,9 +317,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -334,9 +334,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -351,9 +351,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -368,9 +368,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -385,9 +385,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -402,9 +402,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -419,9 +419,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -436,9 +436,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -453,9 +453,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -1835,9 +1835,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1848,32 +1848,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escape-string-regexp": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "2.0.5",
"version": "2.2.4",
"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",
+24 -2
View File
@@ -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)));
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const sourcePath = path.join(repoRoot, 'extension/audio-options.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import { loadLocale, translateDOM, getSystemLanguage } from './i18n.js';", '')
.replace(/init\(\)\.catch[\s\S]*?;\n?$/, '');
function makeInput(overrides = {}) {
return {
checked: false,
value: '',
dataset: {},
addEventListener() {},
...overrides
};
}
const rows = [
['threshold', '-60', '0', '1', false],
['knee', '0', '40', '1', false],
['ratio', '1', '20', '0.5', false],
['attack', '0', '1', '0.001', true],
['release', '0', '1', '0.005', true]
].map(([param, min, max, step, ms]) => {
const range = makeInput({ value: '0', min, max, step });
const number = makeInput({ value: '0', min: ms ? '0' : min, max: ms ? '1000' : max, step, dataset: ms ? { msInput: 'true' } : {} });
return {
dataset: { param },
querySelector(selector) {
return selector === 'input[type="range"]' ? range : number;
}
};
});
const sandbox = {
console,
loadLocale: async () => {},
translateDOM: () => {},
getSystemLanguage: () => 'en',
chrome: {
storage: {
sync: {
get: async () => ({}),
set: () => {},
},
local: {
get: async () => ({}),
set: () => {},
},
onChanged: {
addListener: () => {}
}
}
},
document: {
getElementById: () => makeInput(),
querySelectorAll: (selector) => selector === '.control-row' ? rows : [makeInput({ value: 'recommended' })]
},
setTimeout,
clearTimeout
};
vm.createContext(sandbox);
vm.runInContext(`${source}
globalThis.__audioSettingsTest = {
mergeAudioSettings,
getParamValue,
setCustomParam,
get currentSettings() { return currentSettings; }
};`, sandbox, { filename: sourcePath });
const helpers = sandbox.__audioSettingsTest;
assert.doesNotThrow(() => helpers.mergeAudioSettings(null), 'mergeAudioSettings tolerates null storage values');
assert.doesNotThrow(() => helpers.mergeAudioSettings('bad'), 'mergeAudioSettings tolerates non-object storage values');
assert.equal(helpers.getParamValue('threshold', '-999'), -60, 'threshold clamps to minimum');
assert.equal(helpers.getParamValue('threshold', '999'), 0, 'threshold clamps to maximum');
assert.equal(helpers.getParamValue('knee', '-1'), 0, 'knee clamps to minimum');
assert.equal(helpers.getParamValue('knee', '100'), 40, 'knee clamps to maximum');
assert.equal(helpers.getParamValue('ratio', '0'), 1, 'ratio clamps to minimum');
assert.equal(helpers.getParamValue('ratio', '999'), 20, 'ratio clamps to maximum');
assert.equal(helpers.getParamValue('attack', '-1', true), 0, 'attack ms input clamps to minimum seconds');
assert.equal(helpers.getParamValue('attack', '5000', true), 1, 'attack ms input clamps to maximum seconds');
assert.equal(helpers.getParamValue('release', '-1', true), 0, 'release ms input clamps to minimum seconds');
assert.equal(helpers.getParamValue('release', '5000', true), 1, 'release ms input clamps to maximum seconds');
helpers.setCustomParam('threshold', 999);
assert.equal(helpers.currentSettings.compressor.customParams.threshold, 0, 'setCustomParam stores clamped values');
console.log('audio settings tests passed');
+159 -4
View File
@@ -48,20 +48,52 @@ 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`);
for (const file of localeFiles) {
const filePath = path.join(localesDir, file);
try {
const dict = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const raw = fs.readFileSync(filePath, 'utf8');
// Check for duplicate keys in raw JSON before parsing
const keyRe = /"(\w+)"\s*:/g;
const seenKeys = {};
let dupes = [];
let m;
while ((m = keyRe.exec(raw)) !== null) {
if (seenKeys[m[1]]) {
dupes.push(m[1]);
}
seenKeys[m[1]] = true;
}
if (dupes.length > 0) {
hasError = true;
console.error(`${file} has duplicate keys: ${[...new Set(dupes)].join(', ')}`);
continue;
}
const dict = JSON.parse(raw);
const keys = Object.keys(dict);
const missingKeys = enKeys.filter(k => !keys.includes(k));
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) {
@@ -70,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).`);
}
@@ -79,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);
}
+6 -3
View File
@@ -77,9 +77,12 @@ assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include ave
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
assert.deepEqual(
adminHealth.rateLimitEntries,
{ connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
'admin metrics should expose aggregate rate-limit map sizes'
adminHealth.rateLimits,
{
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }
},
'admin metrics should expose rate-limit tracking and denial counts'
);
console.log('server ops tests passed');
+6 -3
View File
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict';
import {
// Must set env before dynamic import — ESM hoists static imports
process.env.ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
const {
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
HEALTH_RATE_LIMIT_PER_MINUTE,
healthCounts,
@@ -9,9 +12,9 @@ import {
rooms,
startServer,
stopServerForTests
} from '../server/index.js';
} = await import('../server/index.js');
const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
const adminToken = process.env.ADMIN_METRICS_TOKEN;
const baseHeaders = { 'x-forwarded-for': '203.0.113.10' };
function url(path) {
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const localesDir = path.join(__dirname, '..', 'website', 'locales');
const enPath = path.join(localesDir, 'en.json');
if (!fs.existsSync(enPath)) {
console.error('CRITICAL: website/locales/en.json is missing!');
process.exit(1);
}
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
const enKeys = Object.keys(enDict).sort();
const localeFiles = fs.readdirSync(localesDir)
.filter(file => file.endsWith('.json'))
.sort();
let hasError = false;
console.log(`Auditing website i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
for (const file of localeFiles) {
const filePath = path.join(localesDir, file);
try {
const raw = fs.readFileSync(filePath, 'utf8');
// Check for duplicate keys in raw JSON before parsing
const keyRe = /"(\w+)"\s*:/g;
const seenKeys = {};
let dupes = [];
let m;
while ((m = keyRe.exec(raw)) !== null) {
if (seenKeys[m[1]]) {
dupes.push(m[1]);
}
seenKeys[m[1]] = true;
}
if (dupes.length > 0) {
hasError = true;
console.error(`${file} has duplicate keys: ${[...new Set(dupes)].join(', ')}`);
continue;
}
const dict = JSON.parse(raw);
const keys = Object.keys(dict).sort();
const missingKeys = enKeys.filter(k => !keys.includes(k));
const extraKeys = keys.filter(k => !enKeys.includes(k));
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 || emptyKeys.length > 0 || placeholderKeys.length > 0) {
hasError = true;
console.error(`${file} has inconsistencies:`);
if (missingKeys.length > 0) {
console.error(` Missing keys (${missingKeys.length}):`, missingKeys);
}
if (extraKeys.length > 0) {
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
}
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).`);
}
} catch (err) {
hasError = true;
console.error(`❌ Failed to parse ${file}:`, err.message);
}
}
console.log('');
if (hasError) {
console.error('❌ Website locale consistency check failed! Fix the errors above.');
process.exit(1);
} else {
console.log('🎉 All website locale files are perfectly synchronized and consistent!');
process.exit(0);
}
+4 -1
View File
@@ -12,6 +12,7 @@ const checks = [
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
}],
['content video finder', 'node', ['scripts/test-content-video-finder.js']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
@@ -19,6 +20,7 @@ const checks = [
['popup syntax', 'node', ['-c', 'extension/popup.js']],
['background syntax', 'node', ['-c', 'extension/background.js']],
['locale coverage', 'node', ['scripts/test-locales.js']],
['website locale coverage', 'node', ['scripts/test-website-locales.mjs']],
['lint', 'npm', ['run', 'lint']],
['root production audit', 'npm', ['audit', '--omit=dev']],
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
@@ -32,7 +34,8 @@ function runCheck([label, command, args, options = {}]) {
const child = spawn(command, args, {
cwd: options.cwd || repoRoot,
env: { ...process.env, ...(options.env || {}) },
stdio: 'inherit'
stdio: 'inherit',
shell: process.platform === 'win32'
});
child.on('error', reject);
child.on('exit', (code) => {
+8 -5
View File
@@ -1,7 +1,10 @@
PORT=3000
MIN_VERSION=1.0.0
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=25
PORT="3000"
MIN_VERSION="1.0.0"
MAX_ROOMS="1000"
MAX_PEERS_PER_ROOM="25"
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=
ADMIN_METRICS_TOKEN=""
# Optional: set to "1" to enable verbose connection, room-join/leave, and CORS logs in the console. Default is "0" (disabled).
DEBUG_LOGGING="0"
+3
View File
@@ -10,4 +10,7 @@ RUN cd server && npm install --production
COPY server/ ./server/
WORKDIR /app/server
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "index.js"]
+9 -6
View File
@@ -12,13 +12,16 @@ A Node.js relay server for synchronized video playback.
### Environment
Copy `.env.example` to `.env` and configure your settings.
```bash
PORT=3000
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=25
MIN_VERSION=1.0.0
PORT="3000"
MAX_ROOMS="1000"
MAX_PEERS_PER_ROOM="25"
MIN_VERSION="1.0.0"
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=
ADMIN_METRICS_TOKEN=""
# Optional: set to "1" to enable verbose connection, room-join/leave, and CORS logs in the console. Default is "0" (disabled).
DEBUG_LOGGING="0"
```
### Health & Metrics
@@ -51,7 +54,7 @@ docker pull ghcr.io/shik3i/koalasync:latest
# Or build from the repository root
docker build -t koala-sync-server -f server/Dockerfile .
```
See [Docker network compose](../docker-compose.caddy.example.yml) or [Static IP compose](../docker-compose.ip.example.yml) in the root directory for ready-to-use Docker Compose files.
See [Docker network compose](../examples/docker-compose.caddy.example.yml) or [Static IP compose](../examples/docker-compose.ip.example.yml) in the root directory for ready-to-use Docker Compose files.
### Manual Setup
```bash
+79 -14
View File
@@ -82,7 +82,8 @@ app.get('/health', (req, res) => {
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
},
rateLimitDenied
})
));
});
@@ -96,7 +97,7 @@ export const io = new Server(httpServer, {
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
callback(null, true);
} else {
log('CORS', `Rejected origin: ${origin}`);
log('CORS', `Rejected origin: ${(origin || '').replace(/[\r\n]/g, '')}`);
callback(new Error('Not allowed by CORS'));
}
},
@@ -116,6 +117,10 @@ const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
function log(type, message, details = '') {
const debugLogging = process.env.DEBUG_LOGGING === '1';
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS';
if (!debugLogging && isVerbose) return;
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${type}] ${message}`, details);
}
@@ -143,22 +148,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);
@@ -188,6 +193,15 @@ export const healthCounts = new Map(); // ip -> { count, resetTime }
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
// Actual rate-limit denial counters (incremented only when a request is denied)
const rateLimitDenied = {
connections: 0,
events: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0
};
// Clean up connection counts and event counts to prevent memory leak
const rateLimitCleanupInterval = setInterval(() => {
const now = Date.now();
@@ -224,7 +238,9 @@ function checkConnectionRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
connectionCounts.set(ip, entry);
return entry.count <= 10;
if (entry.count <= 10) return true;
rateLimitDenied.connections++;
return false;
}
function checkEventRate(socketId) {
@@ -233,7 +249,9 @@ function checkEventRate(socketId) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
entry.count++;
eventCounts.set(socketId, entry);
return entry.count <= 30;
if (entry.count <= 30) return true;
rateLimitDenied.events++;
return false;
}
function checkHealthRate(ip) {
@@ -242,7 +260,9 @@ function checkHealthRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
healthCounts.set(ip, entry);
return entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE;
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.health++;
return false;
}
function checkAdminMetricsAuthRate(ip) {
@@ -251,7 +271,9 @@ function checkAdminMetricsAuthRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
adminMetricsAuthCounts.set(ip, entry);
return entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE;
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.adminMetricsAuth++;
return false;
}
/**
@@ -581,7 +603,7 @@ io.on('connection', (socket) => {
socket.to(mapping.roomId).emit(eventName, relayPayload);
// --- Side-effects: Server-side Episode Lobby Tracking ---
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) {
room.activeLobby = {
expectedTitle: relayPayload.expectedTitle,
initiatorPeerId: mapping.peerId,
@@ -609,6 +631,7 @@ io.on('connection', (socket) => {
return;
}
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
rateLimitDenied.roomList++;
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
return;
}
@@ -653,6 +676,48 @@ io.on('connection', (socket) => {
}
});
socket.on(EVENTS.PING, (data) => {
if (!checkEventRate(socket.id)) {
socket.disconnect(true);
return;
}
if (!data || typeof data.t !== 'number' || !Number.isFinite(data.t)) return;
if (typeof data.target === 'string' && data.target.length > 0) {
const targetSocketId = peerToSocket.get(data.target);
const senderMapping = socketToRoom.get(socket.id);
if (targetSocketId && senderMapping && data.target !== senderMapping.peerId) {
const targetMapping = socketToRoom.get(targetSocketId);
if (targetMapping && targetMapping.roomId === senderMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.PING, { t: data.t, sender: senderMapping.peerId });
return;
}
}
}
socket.emit(EVENTS.PONG, { t: data.t });
});
socket.on(EVENTS.PONG, (data) => {
if (!checkEventRate(socket.id)) {
socket.disconnect(true);
return;
}
if (!data || typeof data.target !== 'string' || data.target.length === 0) return;
if (typeof data.t !== 'number' || !Number.isFinite(data.t)) return;
const senderMapping = socketToRoom.get(socket.id);
if (!senderMapping || data.target === senderMapping.peerId) return;
const targetSocketId = peerToSocket.get(data.target);
if (!targetSocketId) return;
const targetMapping = socketToRoom.get(targetSocketId);
if (targetMapping && targetMapping.roomId === senderMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.PONG, { t: data.t });
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
roomListCooldowns.delete(socket.id);
@@ -732,7 +797,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.');
+18 -8
View File
@@ -48,7 +48,8 @@ export function buildHealthPayload({
now = Date.now(),
uptime = 0,
memoryUsage = () => process.memoryUsage(),
rateLimitSizes = {}
rateLimitSizes = {},
rateLimitDenied = {}
}) {
const payload = {
status: 'ok',
@@ -75,13 +76,22 @@ export function buildHealthPayload({
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
avgPeersPerRoom,
maxPeersInRoom,
rateLimitEntries: {
connections: rateLimitSizes.connections || 0,
events: rateLimitSizes.events || 0,
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
rateLimits: {
trackedClients: {
connections: rateLimitSizes.connections || 0,
events: rateLimitSizes.events || 0,
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
},
denied: {
connections: rateLimitDenied.connections || 0,
events: rateLimitDenied.events || 0,
health: rateLimitDenied.health || 0,
adminMetricsAuth: rateLimitDenied.adminMetricsAuth || 0,
roomList: rateLimitDenied.roomList || 0
}
},
memory: {
rss: mem.rss,
+1
View File
@@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
+19 -1
View File
@@ -12,6 +12,20 @@ export const APP_VERSION = "1.9.0";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
export const OFFICIAL_SERVER_TOKEN = '62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50';
export const SUPPORT_URL = 'https://support.koalastuff.net';
export const KOFI_URL = 'https://ko-fi.com/koaladev';
export const GITHUB_URL = 'https://github.com/Shik3i/KoalaSync';
export function isFirefox() {
const manifest = chrome.runtime.getManifest();
return !!manifest.browser_specific_settings?.gecko?.id;
}
export function getReviewUrl() {
return isFirefox()
? 'https://addons.mozilla.org/firefox/addon/koalasync/reviews/'
: 'https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc/reviews';
}
export const EVENTS = {
// Connection & Room
@@ -37,7 +51,11 @@ export const EVENTS = {
// Episode Auto-Sync
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel" // Broadcast: cancel active lobby and resume
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
// Ping / Latency
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
PONG: "pong" // server responds with same { t } for client RTT calculation
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
+3 -3
View File
@@ -21,7 +21,7 @@ export const USERNAME_ADJECTIVES = [
'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden',
'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson',
'Turbo', 'Zen', 'Pixel', 'Cyber', 'Solar', 'Lunar', 'Astro', 'Hyper',
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Pixel', 'Warp', 'Frost',
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Nitro', 'Warp', 'Frost',
];
export const USERNAME_NOUNS = [
@@ -39,7 +39,7 @@ export const USERNAME_NOUNS = [
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Opossum', 'Squid',
'Jellyfish', 'Turtle',
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
@@ -102,7 +102,7 @@ export const ANIMAL_EMOJI_MAP = {
'mammoth': '🦣',
'meerkat': '🦦',
'octopus': '🐙',
'opposum': '🐭',
'opossum': '🐭',
'ostrich': '🐦',
'panther': '🐆',
'pelican': '🦩',
+1 -1
View File
@@ -28,7 +28,7 @@ Caddy is the recommended web server. It provides automatic HTTPS and high-perfor
### Recommended Caddyfile
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../Caddyfile.example).
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../examples/Caddyfile.example).
```caddy
sync.koalastuff.net {
+26 -12
View File
@@ -24,12 +24,19 @@ We divide supported languages into two tiers: **Core Languages** (fully hand-cra
| Language Code | Language Name | Verification Status | Rationale / Context |
| :--- | :--- | :--- | :--- |
| `en` | **English** | `100% Manually Verified` | Primary developer language and system default |
| `de` | **German** | `100% Manually Verified` | Core market and compliance baseline |
| `fr` | **French** | `Auto-Generated` | Needs manual native review and polishing |
| `es` | **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
| `pt-BR` | **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
| `ru` | **Russian** | `Auto-Generated` | Needs manual native review and polishing |
| `en` | 🇬🇧 **English** | `100% Manually Verified` | Primary developer language and system default |
| `de` | 🇩🇪 **German** | `100% Manually Verified` | Core market and compliance baseline |
| `fr` | 🇫🇷 **French** | `Auto-Generated` | Needs manual native review and polishing |
| `es` | 🇪🇸 **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
| `pt-BR` | 🇧🇷 **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
| `ru` | 🇷🇺 **Russian** | `Auto-Generated` | Needs manual native review and polishing |
| `it` | 🇮🇹 **Italian** | `Auto-Generated` | Needs manual native review and polishing |
| `pl` | 🇵🇱 **Polish** | `Auto-Generated` | Needs manual native review and polishing |
| `tr` | 🇹🇷 **Turkish** | `Auto-Generated` | Needs manual native review and polishing |
| `nl` | 🇳🇱 **Dutch** | `Auto-Generated` | Needs manual native review and polishing |
| `ja` | 🇯🇵 **Japanese** | `Auto-Generated` | Needs manual native review and polishing |
| `ko` | 🇰🇷 **Korean** | `Auto-Generated` | Needs manual native review and polishing |
| `pt` | 🇵🇹 **European Portuguese** | `Auto-Generated` | Needs manual native review and polishing |
> [!WARNING]
> **Autogeneration Quality Rule**
@@ -155,12 +162,19 @@ In **v2.0**, we extended full internationalization support to the **Browser Exte
* **Locales Directory:** [`extension/locales/`](file:///Users/koala/Documents/KoalaPlay/extension/locales/)
* **Active Dictionaries:**
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (Baseline English)
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (German)
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (French)
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (Spanish)
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (Portuguese (Brasil))
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (Russian)
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (🇬🇧 Baseline English)
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (🇩🇪 German)
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (🇫🇷 French)
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (🇪🇸 Spanish)
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (🇧🇷 Portuguese (Brasil))
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (🇷🇺 Russian)
* [`it.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/it.json) (🇮🇹 Italian)
* [`pl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pl.json) (🇵🇱 Polish)
* [`tr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/tr.json) (🇹🇷 Turkish)
* [`nl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/nl.json) (🇳🇱 Dutch)
* [`ja.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ja.json) (🇯🇵 Japanese)
* [`ko.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ko.json) (🇰🇷 Korean)
* [`pt.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt.json) (🇵🇹 European Portuguese)
* **Translation Engine:** [`extension/i18n.js`](file:///Users/koala/Documents/KoalaPlay/extension/i18n.js)
* **Validation Script:** [`scripts/test-locales.js`](file:///Users/koala/Documents/KoalaPlay/scripts/test-locales.js)
+83 -12
View File
@@ -1,6 +1,22 @@
// KoalaSync Landing Page Logic
document.addEventListener('DOMContentLoaded', () => {
const safeGetLocalStorage = (key) => {
try {
return localStorage.getItem(key);
} catch (_) {
return null;
}
};
const safeSetLocalStorage = (key, val) => {
try {
localStorage.setItem(key, val);
} catch (_) {
return;
}
};
// Scroll Reveal Logic (IntersectionObserver for performance)
const revealElements = document.querySelectorAll('[data-reveal]');
@@ -456,14 +472,14 @@ document.addEventListener('DOMContentLoaded', () => {
// Dynamically localize home links on root dynamic pages (impressum, datenschutz, join)
const localizeHomeLinks = () => {
const activeLang = localStorage.getItem('koala_lang') || (navigator.language.startsWith('de') ? 'de' : 'en');
const activeLang = safeGetLocalStorage('koala_lang') || (navigator.language.startsWith('de') ? 'de' : 'en');
const path = window.location.pathname;
const pathSegments = path.split('/');
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru'].includes(seg));
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'].includes(seg));
// Only need to do this dynamic rewrite if we are NOT already inside a localized subdirectory
if (!isSubdir) {
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="pt-BR/"], a[href="ru/"]');
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="pt-BR/"], a[href="ru/"], a[href="it/"], a[href="pl/"], a[href="tr/"], a[href="nl/"], a[href="ja/"], a[href="ko/"], a[href="pt/"]');
homeLinks.forEach(link => {
link.href = (activeLang === 'en') ? './' : `${activeLang}/`;
});
@@ -477,16 +493,44 @@ document.addEventListener('DOMContentLoaded', () => {
const path = window.location.pathname;
// Save the user's preference
localStorage.setItem('koala_lang', newLang);
safeSetLocalStorage('koala_lang', newLang);
const isLegalImprint = path.includes('impressum') || path.includes('imprint');
const isLegalPrivacy = path.includes('datenschutz') || path.includes('privacy');
if (isLegalImprint) {
let target;
const hasHtml = path.endsWith('.html');
if (newLang === 'de') {
target = hasHtml ? 'de/impressum.html' : 'de/impressum';
if (path.includes('/de/')) target = hasHtml ? 'impressum.html' : 'impressum';
} else {
target = hasHtml ? 'imprint.html' : 'imprint';
if (path.includes('/de/')) target = hasHtml ? '../imprint.html' : '../imprint';
}
window.location.href = target;
return;
} else if (isLegalPrivacy) {
let target;
const hasHtml = path.endsWith('.html');
if (newLang === 'de') {
target = hasHtml ? 'de/datenschutz.html' : 'de/datenschutz';
if (path.includes('/de/')) target = hasHtml ? 'datenschutz.html' : 'datenschutz';
} else {
target = hasHtml ? 'privacy.html' : 'privacy';
if (path.includes('/de/')) target = hasHtml ? '../privacy.html' : '../privacy';
}
window.location.href = target;
return;
}
// Determine if we are on a static landing page versus a dynamic utility page
const isLegalOrJoin = path.includes('impressum') || path.includes('datenschutz') || path.includes('join');
const isIndex = !isLegalOrJoin;
const isIndex = !path.includes('join');
if (isIndex) {
// Static navigation: Route to correct subdirectory
const pathSegments = path.split('/');
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru'].includes(seg));
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'].includes(seg));
let targetPath;
if (newLang === 'en') {
@@ -509,7 +553,7 @@ document.addEventListener('DOMContentLoaded', () => {
} else {
// Dynamic page: Toggle classes and update elements dynamically without navigating away
const html = document.documentElement;
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-pt-br', 'lang-ru');
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-pt-br', 'lang-ru', 'lang-it', 'lang-pl', 'lang-tr', 'lang-nl', 'lang-ja', 'lang-ko', 'lang-pt');
// Fallback dynamic pages to 'en' if 'de' is not chosen (since fr/es markup is not present)
const activeDisplayLang = (newLang === 'de') ? 'de' : 'en';
@@ -533,20 +577,47 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
// Dynamically adjust language select width to fit the selected option's text length
const adjustDropdownWidth = () => {
document.querySelectorAll('.lang-dropdown').forEach(select => {
const tempSpan = document.createElement('span');
const style = window.getComputedStyle(select);
tempSpan.style.fontFamily = style.fontFamily;
tempSpan.style.fontSize = style.fontSize;
tempSpan.style.fontWeight = style.fontWeight;
tempSpan.style.visibility = 'hidden';
tempSpan.style.position = 'absolute';
tempSpan.style.whiteSpace = 'nowrap';
const activeOption = select.options[select.selectedIndex];
if (activeOption) {
tempSpan.textContent = activeOption.textContent;
document.body.appendChild(tempSpan);
const textWidth = tempSpan.getBoundingClientRect().width;
select.style.width = (textWidth + 18) + 'px';
document.body.removeChild(tempSpan);
}
});
};
// Register change event listener for the dropdowns
document.querySelectorAll('.lang-dropdown').forEach(select => {
select.addEventListener('change', handleLanguageChange);
select.addEventListener('change', (e) => {
handleLanguageChange(e);
adjustDropdownWidth();
});
});
// Initialize language select elements to show the current preferred language
const initLanguageSelectorValue = () => {
const savedLang = localStorage.getItem('koala_lang');
const savedLang = safeGetLocalStorage('koala_lang');
const browserLang = navigator.language.startsWith('de') ? 'de' : 'en';
const activePref = savedLang || browserLang;
document.querySelectorAll('.lang-dropdown').forEach(select => {
select.value = activePref;
});
adjustDropdownWidth();
};
// Impressum Email Obfuscation Click Reveal
@@ -618,7 +689,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!isInstalled) {
btn.classList.add('install-breathe');
btn.style.cursor = 'pointer';
btn.onclick = () => window.open('https://addons.mozilla.org/de/firefox/addon/koalasync/', '_blank');
btn.onclick = () => window.open('https://addons.mozilla.org/de/firefox/addon/koalasync/', '_blank', 'noopener');
}
});
illusChrome.forEach(btn => {
@@ -631,7 +702,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!isInstalled) {
btn.classList.add('install-breathe');
btn.style.cursor = 'pointer';
btn.onclick = () => window.open('https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc', '_blank');
btn.onclick = () => window.open('https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc', '_blank', 'noopener');
}
});
illusFirefox.forEach(btn => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

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