Compare commits

...

296 Commits

Author SHA1 Message Date
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
Koala a6be6b2670 perf(server): optimize failedAuthAttempts LRU eviction to O(1) 2026-06-03 11:44:06 +02:00
Koala b51e66d824 docs: add modularization point to ROADMAP 2026-06-03 11:40:04 +02:00
Koala e034882975 docs: update release link in README to v2.0.5 2026-06-03 11:38:07 +02:00
Koala e53a93829e docs: enforce no-manual-version-bumping rule in AI_INIT and SYNC_GUIDE 2026-06-03 11:36:09 +02:00
GitHub Action e3dbf2b8a2 chore(release): update versions to v2.0.5 [skip ci] 2026-06-03 09:34:22 +00:00
Koala 595ea297f5 chore(release): release v2.0.5 2026-06-03 11:33:54 +02:00
GitHub Action a948780745 chore(release): update versions to v2.0.4 [skip ci] 2026-06-03 09:07:20 +00:00
Koala 8c899a6469 Update changelog for v2.0.4 2026-06-03 11:06:44 +02:00
Koala 4f4cdcd8c0 Cache health responses server-side 2026-06-03 11:04:45 +02:00
Koala 602be3a724 Tighten health endpoint hardening 2026-06-03 10:59:08 +02:00
Koala bb7bd21102 Update release assets for v2.0.3 2026-06-03 10:51:47 +02:00
GitHub Action 543bfe074d chore(release): update versions to v2.0.3 [skip ci] 2026-06-03 08:49:25 +00:00
Koala 81c50eff16 Harden room discovery and health metrics 2026-06-03 10:48:49 +02:00
Koala bf0fb5741f fix: version.json path for all non-English locales
Changed condition from lang === 'de' to lang === 'en' so that all
non-English language subdirectories (fr, es, pt-BR, ru, de) correctly
resolve version.json via ../version.json instead of only German.
2026-06-02 14:31:13 +02:00
Timo 6d2355f404 Store Assets added 2026-06-02 09:26:20 +02:00
Koala 732d585273 docs: add v2.0.2 changelog and update README badge 2026-06-02 08:23:49 +02:00
GitHub Action 666808f876 chore(release): update versions to v2.0.2 [skip ci] 2026-06-02 06:17:21 +00:00
Koala 3ec9812265 fix: use server-trusted peerId in relay payload to prevent spoofing 2026-06-02 08:17:08 +02:00
Koala 64f3c5eefd fix: use future-proof regex for Amazon domain matching 2026-06-02 08:06:35 +02:00
Koala 3fe074b308 fix: use orteil.dashnet.org instead of cookieclicker.com in blacklist 2026-06-02 08:04:31 +02:00
Koala a06cca8b9d fix: apply boundary-safe hostname matching to remaining platform and blacklist checks 2026-06-02 08:02:47 +02:00
KoalaDev c4fc3ab53b Merge pull request #4 from Shik3i/alert-autofix-3
Potential fix for code scanning alert no. 3: Incomplete URL substring sanitization
2026-06-02 07:55:08 +02:00
KoalaDev 3f3fea58ed Merge pull request #5 from Shik3i/alert-autofix-2
Potential fix for code scanning alert no. 2: DOM text reinterpreted as HTML
2026-06-02 07:54:51 +02:00
KoalaDev 014169f84e Potential fix for code scanning alert no. 2: DOM text reinterpreted as HTML
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-02 07:51:01 +02:00
KoalaDev 9c61abee03 Potential fix for code scanning alert no. 3: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-02 07:50:35 +02:00
KoalaDev 2f11c60307 Merge pull request #2 from Shik3i/dependabot/npm_and_yarn/server/npm_and_yarn-e5a46ec0e1
chore(deps): bump ws from 8.18.3 to 8.20.1 in /server in the npm_and_yarn group across 1 directory
2026-06-02 07:41:19 +02:00
KoalaDev 27301f8746 Merge pull request #3 from Shik3i/dependabot/npm_and_yarn/server/npm_and_yarn-05b1f1d78b
chore(deps): bump qs from 6.15.1 to 6.15.2 in /server in the npm_and_yarn group across 1 directory
2026-06-02 07:40:26 +02:00
dependabot[bot] 030b839b12 chore(deps): bump qs
Bumps the npm_and_yarn group with 1 update in the /server directory: [qs](https://github.com/ljharb/qs).


Updates `qs` from 6.15.1 to 6.15.2
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.1...v6.15.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 05:39:05 +00:00
dependabot[bot] 5c805bcafa chore(deps): bump ws
Bumps the npm_and_yarn group with 1 update in the /server directory: [ws](https://github.com/websockets/ws).


Updates `ws` from 8.18.3 to 8.20.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.18.3...8.20.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.20.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 05:39:00 +00:00
Koala eb83d9148e Update contact email in CODE_OF_CONDUCT 2026-06-02 07:23:38 +02:00
Koala 290360387f Add CODE_OF_CONDUCT and PULL_REQUEST_TEMPLATE
- Add CODE_OF_CONDUCT.md based on Contributor Covenant 2.1
- Add .github/PULL_REQUEST_TEMPLATE.md with structured sections
- Link CODE_OF_CONDUCT in README.md and CONTRIBUTING.md
2026-06-02 07:22:37 +02:00
Koala c56e404c13 Overhaul issue templates
- Rewrite bug_report.md with professional structure and Copy Logs requirement
- Rewrite feature_request.md to focus on problem, use cases, and benefits
2026-06-02 07:19:49 +02:00
KoalaDev e519ea2302 Update issue templates 2026-06-02 07:16:17 +02:00
Koala 5a75bae8d0 mobile: nav container padding auf 0.5rem reduziert 2026-06-02 06:43:57 +02:00
Koala fc3f4f38ff mobile: Header noch weiter nach links gerückt
- Nav Container Padding auf Mobile reduziert (0.75rem statt 2rem)
- Logo-Area Gap auf 0.25rem reduziert
- Icon auf 40x40 verkleinert
- Schriftgröße auf 1.1rem reduziert
- Vertical margin vom Icon entfernt
2026-06-02 06:41:46 +02:00
Koala f69da7542e mobile: Sprachauswahl immer sichtbar, Hamburger entfernt
- Sprachauswahl aus nav-links in neuen nav-right Container verschoben
- nav-right ist auf Mobile rechtsbündig und immer sichtbar
- Hamburger-Menü entfernt (Links sind Same-Page-Anchors)
- Logo-Bereich auf Mobile kompakter (kleineres Icon, weniger Gap)
- Lang-Selector auf Mobile kompakter (kleinere Icons, weniger Padding)
- Gleiches Restructuring für join.html, impressum.html, datenschutz.html
2026-06-02 06:35:39 +02:00
Timo aba05060b9 feat: add localization comparison row to website comparison table 2026-06-01 17:11:55 +02:00
Timo 0b986e6e13 fix: build-time VERSION injection via build.js instead of JS runtime 2026-06-01 16:27:45 +02:00
Timo b23ce8ee7d fix: dynamic mockup version from version.json instead of hardcoded 1.9.3 2026-06-01 16:25:18 +02:00
Timo 3195bd0089 docs: move older changes into v2.0.0 where they belong 2026-06-01 16:21:35 +02:00
Timo 1c1778d265 docs: remove older changelog entries - attribution unclear 2026-06-01 16:20:36 +02:00
Timo 0262d9c1ad docs: fix changelog dates to match git tag timestamps 2026-06-01 16:19:11 +02:00
Timo f23c329709 docs: move release notice to top of README 2026-06-01 16:18:25 +02:00
Timo 78a165d368 docs: clarify Copy Debug Report description in v2.0.0 2026-06-01 16:16:40 +02:00
Timo 63b5ef0ffd docs: remove website-only entries from changelog, keep extension+server only 2026-06-01 16:16:23 +02:00
Timo a26b07cb5c docs: highlight extension i18n as biggest v2.0.0 feature 2026-06-01 16:15:32 +02:00
Timo a445759dd7 docs: add CHANGELOG.md, link from README, version badge -> clickable changelog 2026-06-01 16:14:30 +02:00
Timo 1e883d8ee4 chore: update assets - add icon folder, remove old SOCIAL_PREVIEW.png 2026-06-01 16:03:40 +02:00
GitHub Action be574e0e25 chore(release): update versions to v2.0.1 [skip ci] 2026-06-01 13:59:31 +00:00
Timo ebf3178e32 feat: multi-video overview in debug report
- GET_VIDEO_STATE now returns allVideos[] with summary per video element
- Copy report shows markdown table when multiple videos on page
- Table includes: resolution, muted, paused, readyState, duration, and marks selected target
- Helps diagnose Prime Video scenarios where preview vs main video both exist
2026-06-01 15:58:31 +02:00
Timo ba96cf2765 fix: prioritize largest video element, fix history action field in debug report
- findVideo() now scores all video elements by size + unmuted + duration
- Fixes Prime Video selecting 0x0 placeholder instead of actual player
- Fix history entries showing '?' by using h.action instead of h.event/h.type
- Update Prime Video status in TESTED_SERVICES.md to partial support
2026-06-01 15:57:11 +02:00
GitHub Action d026ed891a chore(release): update versions to v2.0.0 [skip ci] 2026-06-01 13:50:26 +00:00
Timo d6665fe0f5 feat: v2.0.0 - enhanced debug reports, platform detection, copy report, mockup i18n, new icons
- Copy Logs button now generates full Markdown debug report (system, tab, connection, video, history, logs)
- Enhanced Dev tab with 20+ video state fields (network, dimensions, error, shadow DOM, platform detection)
- Platform auto-detection (YouTube, Netflix, Twitch, Prime Video, Disney+, Hulu, HBO, Vimeo, Dailymotion)
- 'No video found' diagnostic mode with hints
- Convert mockup from inline <span lang> to {{MOCK_*}} template vars (all 6 languages)
- New TwoPointZero branding: icons (16/32/48/96/128), favicons (PNG 16/32 + apple-touch + 192)
- Fix logo vertical centering via display:contents on picture wrappers
- Increase popup logo to 48px, website nav logo to 64px
- AVIF quality 70->80, speed 6->4, remove min 3KB threshold
- Add 32+96 icon sizes to manifest
- Remove amazon. from tab blacklist (unblocks Prime Video)
- Update TESTED_SERVICES.md: mark Prime Video as non-functional
- Update CONTRIBUTING.md with Copy Debug Report workflow
2026-06-01 15:49:55 +02:00
Timo 79025f4d18 feat: new TwoPointZero branding - icons, favicon, mockup i18n
- Replace extension icons with new TwoPointZero design (16/32/48/96/128)
- Update manifest.base.json with 32+96 icon sizes
- Increase popup logo to 48px, website nav logo to 64px
- Generate website webp sizes (64/128/200) + AVIF variants from 600px source
- Add favicon PNGs (16x16, 32x32) + apple-touch-icon + 192x192
- Fix logo vertical centering via display:contents on picture wrappers
- Convert mockup section from inline <span lang> pairs to {{MOCK_*}} template vars
- Add MOCK translations for FR, ES, PT-BR, RU locale files
- Bump AVIF quality 70->80, speed 6->4, remove min 3KB threshold
- Regenerate www/ with build.js
2026-06-01 15:29:33 +02:00
Timo a1f921407c Smart disconnect + human-readable room IDs + expanded word lists
- Add currentServerUrl tracking: only disconnect/reconnect when server URL actually
  changes, otherwise reuse existing connection and just switch rooms
- Add resolveServerUrl() helper for consistent URL resolution across handlers
- forceDisconnect() now resets currentServerUrl to null
- CONNECT and WEB_JOIN_REQUEST handlers: smart comparison before reconnecting
- Add generateRoomId(): human-readable 'ADJECTIVE-NOUN-Number' format (e.g. HAPPY-KOALA-16)
- Replace Math.random() room IDs with generateRoomId() in joinBtn and handleCreateRoom()
- Expand USERNAME_ADJECTIVES: +16 words (Turbo, Zen, Pixel, Cyber, Solar, etc.)
- Expand USERNAME_NOUNS: +12 words (Yeti, Goblin, Pirate, Ninja, Wizard, Storm, etc.)
- Add emoji mappings for all new words
2026-06-01 13:29:02 +02:00
Timo f2669ed769 docs: add relay reachability check hint to readme 2026-06-01 13:27:18 +02:00
Timo 7a6ec8087e Fix WEB_JOIN_REQUEST and joinBtn: custom server invite + password generation
- WEB_JOIN_REQUEST handler: forceDisconnect+connect instead of reusing old socket
  (same bug as CONNECT: JOIN_ROOM was sent to official server instead of custom)
- joinBtn handler: generate random password when roomId is empty and password empty
  (previously created rooms without passwords when using the advanced join button)
- Auto-copy invite link when creating room via joinBtn (consistent with Create Room button)
2026-06-01 13:20:45 +02:00
Timo 8cc622bda0 Fix custom server reconnection and reconnect strategy
- Fix: CONNECT/RETRY_CONNECT always force-disconnect before reconnecting
  (prevents JOIN_ROOM going to official server when custom is selected)
- Add forceDisconnect() helper: clears socket, eventQueue, episodeLobby,
  expectedAcksCount, broadcast status, persists cleanup to storage
- Fix: save useCustomServer to storage on Join Room click (no race condition)
- Fix: trigger RETRY_CONNECT on server mode toggle and custom URL change
- Fix: show error when Custom selected but no URL entered (no silent fallback)
- Fix: button label changed to 'Join / Create Room' in all 6 locales
- Rewrite scheduleReconnect() for two-phase strategy:
  Phase 1 (aggressive): 500ms-5s backoff, max 20 attempts or 5 minutes
  Phase 2 (slow): retry every 5 minutes indefinitely, never give up
- Persist reconnectAttempts/reconnectStartTime/reconnectFailed to session storage
- Remove 'reconnect_failed' status; slow mode shows as 'reconnecting' with retry button
- Add scheduleReconnect() call on offline path to continue retry cycle
2026-06-01 13:06:40 +02:00
KoalaDev 2b5da0dbb7 Modify sync status for YouTube and Twitch
Updated sync status for YouTube and Twitch to indicate episode auto-sync issues.
2026-06-01 12:25:58 +02:00
Timo 1a7ff6b3a7 docs: shorten compose example link text in readme 2026-06-01 12:24:09 +02:00
Timo b1b858d771 docs: replace single docker-compose example with caddy + static ip variants 2026-06-01 12:16:25 +02:00
Timo 608f742f83 fix(i18n): resolve hardcoded strings, missing translation keys, SW notification race conditions, and toast username bugs 2026-06-01 05:47:08 +02:00
Timo b2ff24e155 SEO: add crawlable language navigation links for Google indexing
- Add sr-only CSS class for screen-reader/crawler-visible elements
- Add hidden <nav> with real <a> links to all 6 language versions in template.html
- Googlebot can now discover /de/, /fr/, /es/, /pt-BR/, /ru/ via HTML links
- No visual changes - existing <select> dropdown remains the user-facing switcher
- Rebuild all www/ output with updated CSS hash
2026-06-01 05:36:06 +02:00
Koala 9285e1041e seo: per-locale HowTo schema (8 new keys × 6 languages)
HowTo schema was hardcoded English — now uses {{HOWTO_*}}
placeholders. All 3 rich-result schemas are now locale-aware:
  - FAQPage  (expandable SERP snippets)
  - HowTo    (step-by-step SERP snippets)
  - SoftwareApplication (entity understanding)

SoftwareApplication and Organization use language-neutral
brand name — no locale keys needed.
2026-06-01 04:02:21 +02:00
Koala 3ac110f8f5 seo: update sitemap lastmod + add xhtml:link hreflang alternates
- lastmod bumped to 2026-06-01 for all 6 language roots
- xhtml:link hreflang entries added per URL (Google-recommended
  belt-and-suspenders approach alongside HTML hreflang tags)
2026-06-01 03:58:17 +02:00
Koala cc8c58fac5 seo: per-locale FAQPage schema + Q6 self-hosting mention
- FAQPage schema now uses {{FAQ_Q1}}..{{FAQ_A6}} placeholders
  instead of hardcoded English — each language gets its own
  schema for locale-matched Google Rich Snippets
- Q6 updated across all 6 languages: 'What if I don't trust
  the official server?' + self-hosting Docker mention
2026-06-01 03:56:58 +02:00
Koala 53768980d0 docs: add TESTED_SERVICES.md compatibility matrix; update FAQ Q1
- TESTED_SERVICES.md: table of tested platforms with sync/title/auto-sync columns
- FAQ Q1: explicitly mention Emby + Jellyfin, remove Disney+/Prime mention
- All 6 locale files updated (Q1+A1)
- README links to TESTED_SERVICES.md
2026-06-01 03:54:52 +02:00
Koala 1b04db009a fix: add *.avif to Caddy static cache matcher
PageSpeed Insights flagged AVIF files as uncached because the
@static matcher only included *.webp. Adding *.avif enables
1-year Cache-Control for all AVIF assets.
2026-06-01 03:51:03 +02:00
Koala a2720da5f8 seo: add visible FAQ section with FAQPage schema (6 keyword-targeted Q&As)
Targets high-volume search queries:
- netflix watch2gether / netflix watch in sync
- free teleparty alternative / teleparty alternative
- emby syncplay / jellyfin sync
- watch together extension browsers
- privacy-friendly watch party

Native <details> elements — no JS, Google-visible, Rich-Snippet eligible.
All 6 languages translated (EN/DE/FR/ES/PT-BR/RU).
2026-06-01 03:49:49 +02:00
Koala d2c380d6fc fix: revert store URLs from SoftwareApplication sameAs
Google does not support sameAs for app store links in SoftwareApplication
rich results. sameAs is for identity pages only (Wikipedia, Wikidata, GitHub).
Store pages are independently indexed by Google and need no schema linking.
2026-06-01 03:41:01 +02:00
Koala 1a36b138c0 seo: add Chrome Store and Firefox Add-on URLs to SoftwareApplication sameAs
Google treats SoftwareApplication.sameAs as app listing references
(not social profiles like Organization.sameAs). This connects
KoalaSync's entity graph to both browser stores without
duplication risk.
2026-06-01 03:37:47 +02:00
Koala 47ead27344 docs: improve CONTRIBUTING.md and SECURITY.md readability
- CONTRIBUTING: add ways to contribute table, project structure, setup guide
- CONTRIBUTING: add website testing, translation guide, bug report template
- SECURITY: add scope/out-of-scope, architecture overview, threat model
- SECURITY: structured reporting timeline, responsible disclosure policy
2026-06-01 03:21:59 +02:00
Koala 72180ba817 feat(website): SEO, a11y, and build pipeline overhaul
- SEO: per-locale og:locale, Organization+WebSite schema, FAQPage replacing ItemList
- SEO: BreadcrumbList on legal pages, site.webmanifest, expanded sitemap
- a11y: hamburger ARIA+ESC, skip-to-content, aria-hidden on step-num
- a11y: focus-visible states, prefers-reduced-motion, emoji→SVG icons
- CSS: extract .mock-section-label, replace transition:all, will-change hints
- Build: esbuild JS minifier (-46%), sharp AVIF conversion (26 files, quality 70)
- Build: content hashing (style.XXXX.min.css), SVG minification, picture injection
- Cleanup: remove lightningcss (caused CSS merging bugs)
2026-06-01 03:20:51 +02:00
Koala 9eab699e2a feat(website): output minified files as .min.* for safety and clarity 2026-06-01 02:14:31 +02:00
Koala c95e72b713 docs: document build minifier and warn against editing www/ directly 2026-06-01 02:12:05 +02:00
Koala 14be38fe11 feat(website): add build-time minification and optimize meta descriptions 2026-06-01 02:10:27 +02:00
Koala 1bb2123da2 feat(website): technical SEO and UI/UX accessibility improvements
- Add LCP image preload for hero mascot
- Add HowTo structured data schema (3-step install guide)
- Update SoftwareApplication schema with screenshot + image fields
- Add glassmorphic :focus-visible outlines for keyboard accessibility
- Add content-visibility: auto to heavy offscreen sections for INP
- Expand lang-init.js auto-redirect to all 6 supported languages
- Remove impressum/datenschutz from sitemap.xml (noindex conflict)
2026-06-01 01:57:16 +02:00
Koala e29c6666b6 feat(website): redesign bento tiles in 'Why KoalaSync' section
- Swap last two tiles: Invitation becomes 2nd-last (bento-large),
  Self-Hosting becomes last small tile
- bento-large layout: icon centered above heading, 33/67 grid split
- Fix icon consistency on small tiles (uniform 44x44 rounded squares)
- Improve responsive breakpoints for all viewports
- Delete legacy website/index.html (obsoleted by template + build.js)
2026-06-01 01:42:42 +02:00
Koala b08e8ba06b docs: update advanced caddyfile configuration for syncserver in example and website 2026-06-01 01:20:31 +02:00
Koala 30a4057c99 docs: update documentations to reflect browser extension i18n features 2026-06-01 01:07:41 +02:00
Koala fd47eb82b9 feat(extension): implement full dynamic i18n support and automated consistency checks 2026-06-01 01:05:46 +02:00
Koala 3fcafbd081 fix(eslint): resolve all 6 repository lint errors
- Removed unused html variable from localizeHomeLinks in website/app.js.
- Added website/build.js to ESLint flat config Node files match pattern to define require, process, and __dirname.
2026-06-01 01:04:47 +02:00
Koala d430501b82 feat(website): update sitemap.xml to index clean URLs
- Replaced /impressum.html and /datenschutz.html with clean URLs /impressum and /datenschutz in sitemap.xml.
- Recompiled website to copy clean sitemap to www/ folder.
2026-06-01 00:23:54 +02:00
Koala d738352c90 feat(website): implement Clean URLs and split Caddy configurations
- Removed all .html file extension suffixes from website and invite links (impressum, datenschutz, join).
- Updated app.js dynamic localizer and path switching to target Clean URLs ('./', '../', etc.).
- Refactored Caddyfile.example into simple (lightweight try_files) and advanced (hardened production) options.
- Split Caddy configs into Caddyfile (Simple) and Caddyfile (Advanced) tabs on the landing page.
2026-06-01 00:21:29 +02:00
Koala dbf1b3e81b docs(translation): refine TRANSLATION.md for better clarity and human readability
- Restructured translation guide with clean markdown layout and tables.
- Removed mermaid sequence diagram to improve text-based clarity.
- Audited steps for contributing new language keys.
2026-05-31 23:54:44 +02:00
Koala 5a3f1c7019 docs(website): update Caddyfile configuration and website README for www/ path
- Corrected static server root path to website/www/ in Caddyfile.example and website/README.md.
- Updated core roles to reflect multi-language (EN/DE/FR/ES/PT-BR/RU) support.
- Refactored local development and compilation instructions to target build.js and www/ output.
2026-05-31 23:50:49 +02:00
Koala 57b0dd1632 feat(website): implement custom i18n static compiler & full 6-language expansion
- Added pure Node.js dynamic i18n static site generator (build.js).
- Structured locales for English, German, French, Spanish, Brazilian Portuguese, and Russian.
- Replaced two-state toggle with premium glassmorphic language select dropdown.
- Integrated robust segment-based locale routing with safe dynamic fallbacks for legal and invite pages.
- Audited Core Web Vitals (LCP preloads, CLS dimensions) and SEO structures (robots, sitemap).
- Added dedicated Localization section to README and created contributor TRANSLATION guide.
2026-05-31 23:49:36 +02:00
Koala b6f7c1ccdb refactor(datenschutz): unify email obfuscation to .email-reveal (scraper-safe) 2026-05-31 00:47:39 +02:00
Koala 4bada9533a feat(impressum): add name (Timo/KoalaDev), Mastodon for private msgs, GitHub Issues for concerns 2026-05-31 00:43:58 +02:00
Koala df32385fa6 fix(website): correct Mastodon SVG icon path to official branding 2026-05-31 00:22:47 +02:00
Koala ce9778245d feat(website): add Mastodon profile link (rel=me) to all page footers 2026-05-31 00:16:18 +02:00
Koala 5d6c0cd1fb fix(seo): shorten title (<70 chars), meta description (<160 chars), fix duplicate h1 2026-05-30 21:43:32 +02:00
Koala bbba50f643 fix(website): add responsive srcset to nav logo (40w/80w/200w) 2026-05-30 21:26:36 +02:00
Koala 56293209f8 fix(website): switch from x-descriptors to w-descriptors + sizes for responsive images to satisfy PageSpeed Insights 2026-05-30 21:21:51 +02:00
Koala c55691a535 feat(website): add responsive images with srcset (1x/2x WebP) for all mascot and illustration images 2026-05-30 21:16:09 +02:00
Koala ce3b17f55f feat(website): add hreflang tags, comparison JSON-LD schema, and update sitemap with lastmod 2026-05-30 20:58:35 +02:00
Koala 5e8e9f61c2 fix(website): use Web Animations API for hero button pulse, avoids CSS transition/inline-style conflicts from mouse handlers 2026-05-30 20:50:34 +02:00
Koala 59eb1a6092 fix(website): apple-mobile-web-app-capable deprecated meta tag + download button pulse animation 2026-05-30 20:45:08 +02:00
Timo 00c9ff8cfc feat(website): Add smart browser-badge breathing animation, nav installed indicator, clickable smooth-scroll footnotes, and mockup height layout adjustments 2026-05-30 20:35:48 +02:00
Timo 748ccaa835 Refactor(website): Overhaul Getting Started section with extension-accurate workflows, premium CSS animations, stacked browser badges, and refined German translations 2026-05-30 20:25:36 +02:00
Timo 25b23d083d feat(website,extension): integrate NewLogoIcon as WebP favicon/header logo and convert to PNG extension icons 2026-05-30 20:14:59 +02:00
Timo 74042c3c78 docs(readme): replace header icon with Juggler mascot and feat(website): optimize technical SEO & add mobile theme colors 2026-05-30 19:55:25 +02:00
Timo fb1451022b feat(website): integrate KoalaDeploy mascot, enlarge Juggler mascot by 15%, and add premium interactive hover effects to all main mascots 2026-05-30 19:48:53 +02:00
Timo 54ffbfa4bd feat(website): integrate KoalaSearching, KoalaImprintl, and KoalaPrivacy mascots with custom animations and tight layouts 2026-05-30 19:05:35 +02:00
Timo 0db0b5eea2 feat(website): add KoalaThumbsUp and KoalaThumbsDown status mascots on invite page, hide pulsing ring, and support dev simulation mode 2026-05-30 18:22:05 +02:00
Timo d58041bd4e feat(website): add lookdown koala to hero, questions koala to features, pro-contra koala to comparison, translate learning tile to Gemeinsam Lernen, and refine copy 2026-05-30 17:50:03 +02:00
Timo 1f1b99a1fe feat(website): add koala holding git page next to octocat in bottom CTA and improve LDR couple alt text description 2026-05-30 16:57:31 +02:00
Timo c608d2d9e9 style(website): standardize use-case image container heights to 150px and set object-fit contain to align headings 2026-05-30 16:40:55 +02:00
Timo 06203eb5cb feat(website): replace LDR emoji with hugging koala couple and translate title to Fernbeziehungen 2026-05-30 16:39:03 +02:00
Timo ed599a96ab feat(website): replace remote learning emoji with professor koala mascot in use cases grid 2026-05-30 16:11:44 +02:00
Timo 5d1707a7d7 feat(website): replace movie night emoji with popcorn-sharing koalas mascot and optimize card padding 2026-05-30 15:59:38 +02:00
Timo f4343ca6b1 feat(website): add juggling koala mascot to platforms section and optimize spacing 2026-05-30 15:43:01 +02:00
Koala 3644a7b8ac website: refactor interactive extension mockup and align styling with popup UI
- Update mockup tabs layout, colors, inputs, cards, and spacing to perfectly mirror popup.html 1-to-1.
- Enable full interactivity for toggle switches in Settings mockup screen.
- Link the top mockup version badge directly to the GitHub repository with smooth CSS hover transitions and cursor pointer.
- Port all hover tooltips (title attributes) from popup.html to the mockup index.html.
- Add mandatory caveman communication protocol to AI_INIT.md onboarding instructions.
2026-05-30 13:14:47 +02:00
Koala c621c851af style(website): optimize SEO, UX, accessibility, and navigation icons 2026-05-30 11:49:04 +02:00
Koala 1113fb8bb5 Fix app.js brace syntax error and remove duplicate tbody in index.html 2026-05-30 11:24:13 +02:00
Koala c852826091 Enhance Join page with animated radar status indicator, resolve silent status icon bug, and clean inline script button styles to CSS 2026-05-30 11:20:40 +02:00
Koala cfef79ec1d Refactor comparison table categories to be neutral, consolidate privacy footnotes, and add comparison date stamp 2026-05-30 11:18:06 +02:00
Koala 8fbada8b03 Add clickable proof footnotes to Teleparty comparison table linking to official policy pages 2026-05-30 11:14:04 +02:00
Koala 2261a8133a Refactor landing page use-cases, browser CTA badging, official brand SVGs, and streamlined competitor comparison 2026-05-30 11:13:29 +02:00
GitHub Action d5241b19e8 chore(release): update versions to v1.9.3 [skip ci] 2026-05-30 00:01:24 +00:00
Timo f7096edd30 Refactor: Behebung Force-Sync ACK-Zaehler-Inflation, SHA-256 Hashing, Room-ID-Desinfizierung, Reduzierung MIN_SEEK_DELTA auf 2s und seekDebounce auf 300ms 2026-05-30 02:00:17 +02:00
GitHub Action e2b76b05a1 chore(release): update versions to v1.9.2 [skip ci] 2026-05-29 23:35:36 +00:00
Timo 93acd0b44c fix: resolve F-01 to F-08 audit bugs and QoL improvements 2026-05-30 01:34:48 +02:00
GitHub Action ce7b5c47f2 chore(release): update versions to v1.9.1 [skip ci] 2026-05-29 22:08:39 +00:00
Timo 92fb8e2d00 Roll back network latency compensation to maximize simplicity and clock skew safety 2026-05-30 00:06:52 +02:00
Timo 43cde9bbef Optimize network latency compensation cap to protect against system clock skew 2026-05-30 00:03:56 +02:00
Timo 56955027f9 Fix critical sync lockouts, zombie disconnects, play-seek debounces, active lobbies, SW keep-alives, branding matches and ESLint warnings 2026-05-30 00:02:19 +02:00
GitHub Action 5ef059a94f chore(release): update versions to v1.9.0 [skip ci] 2026-05-28 02:55:04 +00:00
Koala 98b4fc5fb4 release: v1.9.0 — command sequencing, episode-aware sync, echo suppression
- Add per-peer monotonically increasing seq numbers (localSeq + lastSeqBySender)
- Server relays seq for stale command detection (backward compatible)
- Replace expectedEvents (1500ms timeout) with _suppressTimers (per-type, 300ms)
- Fix FORCE_SYNC_ACK missing seq (stale-ACK guard)
- Fix episode lobby readyPeers asymmetry (initiator now included)
- Add extractEpisodeId() parsing S01E01, Season 1 Ep 1, Folge 5, Ep. 3, #42
- Add isDifferentEpisode() guard blocking cross-episode sync commands
- Add sameEpisode() for format-tolerant lobby title matching
- Guard controlled via autoSyncNextEpisode setting
- Reduce reactive lock 1000ms → 300ms
- Fix routeToContent unbounded retry (max 3 attempts)
- Fix server bcrypt.hash failure crashing join flow
- Fix sameEpisode(null, title) returning true
- Fix forceSyncTimeout leak on rapid force sync clicks
- Persist lastSeqBySender across service worker restarts
- Bump version: 1.8.10 → 1.9.0
2026-05-28 04:54:54 +02:00
Koala 762d6425be fix: audit corrections for sameEpisode, CMD_ACK, lastSeqBySender, forceSyncTimeout
- sameEpisode: return false when one title is null (prevent false lobby completions)
- Episode guard: skip CMD_ACK for FORCE_SYNC_EXECUTE on mismatch (H1)
- Persist lastSeqBySender in chrome.storage.session (M3, survives SW restart)
- Clear forceSyncTimeout before overwriting (H3, pre-existing timer leak)
2026-05-28 04:45:52 +02:00
Koala 1fba2fb69c perf: add command sequence numbers, episode-aware sync guard, fix echo suppression
- Add monotonically increasing seq per peer (localSeq + lastSeqBySender)
- Server relays seq field for stale command detection (backward compatible)
- Replace expectedEvents (1500ms timeout) with _suppressTimers (per-type, 300ms)
- Fix FORCE_SYNC_ACK missing seq (stale-ACK could trigger premature exec)
- Fix episode lobby readyPeers asymmetry (initiator never broadcasted readiness)
- Add extractEpisodeId() parsing S01E01/Season 1 Episode 1/Folge 5 patterns
- Add isDifferentEpisode() guard blocking sync commands across different episodes
- add sameEpisode() for format-tolerant lobby title matching
- Guard controlled by autoSyncNextEpisode setting with disable hint in warning
- Reduce reactive lock from 1000ms to 300ms (seq numbers handle ordering)
2026-05-28 04:30:32 +02:00
Koala eca259281a feat: expand emoji/name system with comprehensive animal mapping
- Create shared/names.js as single source of truth for emoji maps, noun
  lists, adjectives, and username generation (215 animal emoji entries)
- Remove duplicated inline lists from popup.js and background.js; both
  now import getAvatarForName / generateUsername from shared/names.js
- Add names.js to the build script sync list so it stays DRY across
  extension builds
- Fix missing cat emoji: CoolCat now correctly resolves to 🐱
- Sort emoji map keys by length at lookup time to prevent substring
  false-matches (e.g. 'caterpillar' before 'cat')
2026-05-28 03:35:17 +02:00
GitHub Action afd28be2e6 chore(release): update versions to v1.8.10 [skip ci] 2026-05-26 15:42:24 +00:00
Koala cc0265c836 v1.8.10: fix description length, solo peer detection, tab-close peer notify, lastHeartbeat guard, time interpolation stale guard 2026-05-26 17:42:11 +02:00
GitHub Action 6ebff9ab4c chore(release): update versions to v1.8.9 [skip ci] 2026-05-26 01:07:16 +00:00
Koala 35e779c1ff move version link outside h1 to avoid inherited heading styles 2026-05-26 03:00:29 +02:00
Koala 09f0e04891 add clickable version with GitHub icon to popup header 2026-05-26 02:58:36 +02:00
Koala 9eff53ba46 chore: remove CSP-blocked inline handlers from landing page 2026-05-26 02:47:45 +02:00
GitHub Action bde2f7ea55 chore(release): update versions to v1.8.8 [skip ci] 2026-05-26 00:34:04 +00:00
Koala 901861269a fix: Firefox invite — cloneInto() for CustomEvent detail (XrayWrapper) 2026-05-26 02:33:54 +02:00
GitHub Action ba91e2744c chore(release): update versions to v1.8.7 [skip ci] 2026-05-26 00:06:39 +00:00
Koala b7a44024ab fix: suppress ghost seek events on tab re-focus (Firefox tab throttling) 2026-05-26 02:06:23 +02:00
Koala 3c49bfe54c feat: SEO — add Emby/Jellyfin to descriptions, optimize meta tags, fix APP_VERSION 2026-05-26 01:51:34 +02:00
GitHub Action d2ea7c7423 chore(release): update versions to v1.8.6 [skip ci] 2026-05-25 23:07:55 +00:00
Koala db11812bd6 fix: suppress seek events when solo, add moz-extension CORS, add server logging 2026-05-26 01:06:04 +02:00
GitHub Action 6db8fdbf75 chore(release): update versions to v1.8.5 [skip ci] 2026-05-25 21:56:58 +00:00
Koala d23c37f87f chore(release): v1.8.5 2026-05-25 23:56:50 +02:00
Koala acd428d4f7 fix: audit fixes — auto-match title corruption, duplicate HTML attrs, stale ℹ️ icons, missing tooltip, remove fix_ui.js 2026-05-25 23:56:14 +02:00
Koala c0a6f0adc2 ui: clipboard emoji for invite button, movie emoji for audible tab indicator 2026-05-25 23:56:14 +02:00
GitHub Action 42b73bb97f chore(release): update versions to v1.8.3 [skip ci] 2026-05-25 21:43:51 +00:00
Koala dc36bfdded chore(release): v1.8.3 2026-05-25 23:43:41 +02:00
Koala 06db850387 fix(ui): add comprehensive tooltips to all labels, buttons and inputs 2026-05-25 23:43:09 +02:00
Koala 0de92b5b61 fix(ui): tooltips, full emoji map, and onboarding layout 2026-05-25 23:37:21 +02:00
GitHub Action 8ff8e7beb6 chore(release): update versions to v1.8.1 [skip ci] 2026-05-25 21:32:16 +00:00
Koala 8317099072 chore(release): prep for v1.8.1 2026-05-25 23:32:07 +02:00
Koala 04c63dcf68 fix(ui): add missing avatar emoji in episode lobby UI 2026-05-25 23:30:26 +02:00
Koala fd23ccc23a feat(ui): v1.8.1 visual upgrades (emojis, animations, audio indicator) 2026-05-25 23:29:06 +02:00
Koala 4d5caeda9e style(ui): redesign sync button layout and rename to SYNC 2026-05-25 23:20:37 +02:00
Koala f1f41e5cac fix(ui): apply tooltip to full label text for settings 2026-05-25 23:19:23 +02:00
GitHub Action 473eacda22 chore(release): update versions to v1.8.0 [skip ci] 2026-05-25 21:09:07 +00:00
Koala 42029f86bf chore(release): prep for v1.8.0 2026-05-25 23:08:55 +02:00
Koala 4c4a2638d7 docs: rearrange README to prioritize user quick start 2026-05-25 23:06:58 +02:00
Koala 5b57970c4c docs: use for-the-badge style for download links 2026-05-25 23:05:22 +02:00
Koala 61492f953b docs: Update README quick start for users 2026-05-25 23:04:14 +02:00
Koala da6a1cc643 Server updates 2026-05-25 22:59:54 +02:00
Koala 62fdffa5ee UX Improvements: Onboarding, UI Feedback, Auto-Copy Invite 2026-05-25 22:59:54 +02:00
GitHub Action fb13978c9d chore(release): update versions to v1.7.5 [skip ci] 2026-05-25 11:26:16 +00:00
Koala 82c09a5328 fix(popup): use hostname-aware domain matching to prevent false positives
The noise filter used naive String.includes() on the full URL, causing
blacklist entry 'x.com' to match netflix.com (since 'netflix.com'
contains 'x.com' as substring). This made Netflix tabs disappear when
the filter was enabled.

Fixed by extracting the URL hostname and matching at domain boundaries:
- Domain entries (x.com): hostname === domain || endsWith('.' + domain)
- Prefix entries (amazon.): startsWith || includes('.' + domain)
- Keyword entries (jira): substring fallback on full URL

Release v1.7.5
2026-05-25 13:26:01 +02:00
Koala 2fbeafeb3f fix(website): 'any' → 'almost any' for honesty 2026-05-25 13:01:59 +02:00
Koala 80f8c821cb docs: clarify that website and documentation changes do not need release tags 2026-05-25 13:00:38 +02:00
GitHub Action 7d3965a9fd chore(release): update versions to v1.7.4 [skip ci] 2026-05-25 10:59:16 +00:00
Koala 1aca6c37d4 fix(website): replace misleading 'local MP4s' with accurate platform list
The hero subtitle claimed support for 'local MP4s' which makes no sense
for a browser-based synchronization tool (local files can't be synced
across different computers). Changed to accurately reflect actual platform
support: YouTube, Twitch, Netflix, and any website with a video player.
2026-05-25 12:59:00 +02:00
GitHub Action aa740592dd chore(release): update versions to v1.7.3 [skip ci] 2026-05-25 10:52:55 +00:00
Koala 6f8bcf8478 fix(popup): replace innerHTML with DOM API to fix Firefox warnings
Firefox flagged unsafe innerHTML assignments:
- renderEmpty() container.innerHTML with template literal
- renderOnboardingStep() dots.innerHTML with mapped content
- updateLastActionUI() lastActionCard.innerHTML static string
- refreshRooms click handler publicRooms.innerHTML

Replaced all with createElement + textContent/replaceChildren.
2026-05-25 12:52:33 +02:00
GitHub Action 807a620fe9 chore(release): update versions to v1.7.2 [skip ci] 2026-05-25 10:46:16 +00:00
Koala 6e138f51d3 docs: enforce tag immutability and force-push policy in AI_INIT.md
- Tags are permanent once pushed; never reuse or move existing tags
- If a release is missing a fix, increment version and create new tag
- Force pushing (branches or tags) requires explicit user confirmation
- Added to Section 10 (Release Workflow) for AI agents
2026-05-25 12:43:36 +02:00
GitHub Action ed50e354ab chore(release): update versions to v1.7.0 [skip ci] 2026-05-25 10:40:34 +00:00
Koala 48bd503b5c fix(popup): seed lastKnownPeers in init() to prevent false join toasts
init() called updatePeerList() directly but never set lastKnownPeers.
On the first PEER_UPDATE message (heartbeat, play/pause, any state
change), detectPeerChanges() saw lastKnownPeers=[] and treated ALL
peers including self as newly joined — triggering 'name joined the room'
toast on every action while the popup was open.
2026-05-25 12:40:05 +02:00
GitHub Action 35351bdacd chore(release): update versions to v1.7.0 [skip ci] 2026-05-25 10:30:48 +00:00
Koala a0063d42b1 docs: consolidate duplicate auth failure retention rows in privacy table 2026-05-25 12:30:20 +02:00
Koala 8b3f9e1242 fix: sync FORCE_SYNC_TIMEOUT constant with actual usage, tokenize showError cleanup, fix EVENT_ACK indent
- FORCE_SYNC_TIMEOUT in shared/constants.js was 5000 but all code uses
  8500ms. Update constant to 8500 and reference it in background.js
  instead of hardcoded values (4 occurrences)
- Add errorToken counter in popup showError() so stale 5s timeout
  doesn't clear styling of a newer error that arrived in between
- Fix EVENT_ACK handler indentation in server/index.js to match
  surrounding socket.on handlers
2026-05-25 12:28:22 +02:00
Koala eb5515fc1b fix(extension): ensureState timeout guard, skip queued LEAVE_ROOM, persist onclose state, cleanup forceSync timer
- Add 10s timeout to ensureState() so extension doesn't hang forever if
  chrome.storage.session.get() never calls back (storage API failure)
- Skip emit(LEAVE_ROOM) in leaveOldRoomIfSwitching when socket is down;
  server already cleaned up via disconnect handler, avoid queued no-op
- Persist cleared peers to storage in socket.onclose to prevent stale
  peer list restoration on service worker restart
- Store and clean up forceSyncReset setTimeout in popup unload handler
  and when force_sync_execute completes
2026-05-25 12:26:21 +02:00
Koala c621685aae fix: use real client IP for auth rate limiting; preserve lobby across socket disconnect
- Store real client IP from x-forwarded-for on socket for use in JOIN_ROOM
  auth rate limiting (was using proxy IP, breaking brute-force protection)
- Remove clearEpisodeLobbyState() from socket.onclose to preserve lobby
  across brief disconnects; ensureState() recovers lobby + timeout on reconnect
- Lobby is still properly cleared on intentional LEAVE_ROOM and room switch
2026-05-25 12:24:14 +02:00
Koala b98cfc9ca1 fix(server): add missing room creation lock to prevent concurrent join race
Two parallel JOIN_ROOM to a non-existent room could race past each
other during bcrypt.hash, causing the second to overwrite the first
room (password hash lost). The lock was read but never written.

- Create lock promise before bcrypt.hash async boundary
- Release in finally to cover all exit paths (success, MAX_ROOMS, error)
- Concurrent waiters now correctly await existing room creation
2026-05-25 12:23:16 +02:00
Koala 6f08a9d7c4 fix: restore auth failure records retention line, update to 15 minutes 2026-05-25 11:57:37 +02:00
Koala 440ed2db47 docs: update PRIVACY.md and ARCHITECTURE.md for v1.7.0 changes 2026-05-25 11:56:46 +02:00
Koala ed24a4c263 fix: commandSenderMap race - embed senderId in SERVER_COMMAND/CMD_ACK instead of global Map 2026-05-25 11:52:05 +02:00
Koala 284b82a910 fix: v1.7.0 - critical bug fixes, race conditions, memory leaks, null guards, server hardening 2026-05-25 11:48:54 +02:00
Koala f59d30569e perf: optimize reconnect flow - aggressive backoff (500ms→5s), reconnect UI status, 30s keepAlive 2026-05-25 10:29:19 +02:00
Koala f23c7eb3c8 docs: remove unapproved features from roadmap 2026-05-25 10:14:44 +02:00
Koala 9a4dd41555 Re-add Netflix to supported platforms — works via HTML5 video tag 2026-05-25 10:12:11 +02:00
Koala 2067f76ced improve onboarding copy - add welcome step, friendlier text, 5 steps 2026-05-25 10:08:02 +02:00
Koala 3b65af1bbb feat: sprint 2 - empty states, onboarding tour, dev tab optimization, expanded usernames
- Add renderEmpty() helper with icons and hints for peers/history/logs/rooms
- Implement onboarding tour (4 steps) with overlay, dots, skip/next
- Dev tab logs only poll when tab is visible (isDevTabVisible flag)
- Expand username generation from 12/12 to 30/30 adjective-noun pairs
- Update ROADMAP.md to remove implemented features
2026-05-25 10:05:47 +02:00
Koala bb316340a7 Add remaining koalastuff.net subdomains to blacklist 2026-05-25 10:02:28 +02:00
Koala a3af397fe9 Add koalastuff.net and snippets.koalastuff.net to blacklist 2026-05-25 10:01:27 +02:00
Koala 0ac2b49d89 Add sync.koalastuff.net to domain blacklist 2026-05-25 09:57:31 +02:00
Koala c9f93dc4ba fix: add null guards to prevent runtime crashes
- popup.js: guard peers/state.acks in updateLastActionUI()
- popup.js: guard state.duration in refreshDebugInfo()
- popup.js: guard elements.lobbyPeerStatus in updateLobbyUI()
- background.js: use Array.isArray() for currentRoom.peers.length checks
2026-05-25 09:57:01 +02:00
Koala 4909a86a13 feat: implement sprint 1 quick wins - toast system, notifications, UX polish
- Add central toast notification system (popup.html, popup.js)
- Add browser notifications toggle (opt-in) with event toasts
- Fix interpolation memory leak (unload listener)
- Add /health endpoint with IP-based rate limiting (server)
- Improve tab sorting (current tab first, matches, alphabetical)
- Add copy-to-clipboard visual feedback with toast
- Show targetTime in last action card for seek/force sync
- Add explicit video cleanup when element removed (content.js)
- Update ROADMAP.md to remove implemented features
2026-05-25 09:53:25 +02:00
Koala d66c68be5d Use normal font size for italic description 2026-05-25 09:52:14 +02:00
Koala 23d4b7068e Center and shrink description text in README 2026-05-25 09:50:48 +02:00
Koala f40d4e8de4 Center heading and italicize description in README 2026-05-25 09:50:01 +02:00
Koala e996275c2a Revert "Restore centered banner design with italic tagline and honest copy"
This reverts commit dd37045afc.
2026-05-25 09:49:35 +02:00
Koala dd37045afc Restore centered banner design with italic tagline and honest copy 2026-05-25 09:46:54 +02:00
Koala 92bec29215 Remove exaggerated marketing claims and fix technical inaccuracies across docs and website 2026-05-25 09:37:13 +02:00
Koala 552afac26a docs: add comprehensive roadmap with implementation plans 2026-05-25 09:36:42 +02:00
Koala 3c671bcfab Expand blacklist with more mail, search, and social domains 2026-05-25 09:35:03 +02:00
Koala 18ed8953db Add more domains to tab blacklist 2026-05-25 09:24:49 +02:00
Koala 967fe8872e Split Chrome/Firefox badges and link to web stores 2026-05-25 08:44:31 +02:00
GitHub Action 0e93e31bd0 chore(release): update versions to v1.6.1 [skip ci] 2026-05-25 00:49:48 +00:00
Timo 19ddfd1e21 Add ESLint setup, fix 6 lint errors, update AI_INIT.md with lint checks 2026-05-25 02:46:29 +02:00
Timo 4d85362020 Fix 5 frontend audit findings: FORCE_SYNC_ACK timeout, expectedTimeouts leak, undefined var, unreachable error handler, Smart Match perf 2026-05-25 02:35:02 +02:00
Timo 53c1b8eea3 feat(website): add Chrome Web Store link to download buttons 2026-05-25 02:30:15 +02:00
Timo 2b2aeeba00 fix(extension): compact Last Activity Status card with scroll and smaller elements 2026-05-25 02:28:33 +02:00
Timo 5067b8e541 fix(extension): exclude own tab from smart match and send immediate heartbeat after injection 2026-05-25 02:24:50 +02:00
Timo 8778403449 docs: add mandatory pre-session git pull instruction to AI_INIT.md 2026-05-25 02:17:50 +02:00
GitHub Action af87e34f5d chore(release): update versions to v1.6.0 [skip ci] 2026-05-25 00:15:24 +00:00
Timo 385602c194 fix(extension,server): resolve 12 audit findings (lobby cleanup, dedup race, protocol version, rate limiting, observer scope, and more) 2026-05-25 02:14:59 +02:00
Timo bf0fa55b9d feat: add ForceSync mode selector (Jump to Others / Jump to Me) 2026-05-25 02:14:59 +02:00
GitHub Action f063fd5f3d chore(release): update versions to v1.6.0 [skip ci] 2026-05-22 22:19:12 +00:00
Koala 0a555942f8 chore: release v1.6.0 and apply fixes 2026-05-23 00:19:02 +02:00
GitHub Action d3f680e313 chore(release): update versions to v1.5.4 [skip ci] 2026-05-18 19:39:54 +00:00
Timo e8203419a3 fix(extension): eliminate potential async race condition in slow reconnect by capturing attempt flag synchronously 2026-05-18 21:37:39 +02:00
Timo e3536ad1a7 refactor(extension): implement infinite, low-frequency background reconnect checks after initial 5-minute failure 2026-05-18 21:34:46 +02:00
Timo aa1382b2ad security(website): refactor inline scripts and click handlers to external files to achieve 100% strict CSP compliance 2026-05-18 21:21:38 +02:00
Timo 44ee3fab25 docs(website): add static asset caching and strict CSP template configurations in Caddyfile examples 2026-05-18 21:13:29 +02:00
Timo e9b55c72f0 style(website): replace emoji in Step 1 illustration with official brand logo 2026-05-18 20:52:19 +02:00
Timo a83cdf4b03 feat(website): link hero github button to releases and add packages version link under docker pane 2026-05-18 20:51:24 +02:00
Timo 3fb48ee822 feat(website): expand features grid from 4 to 6 symmetrical tiles including self-hosting and invites 2026-05-18 20:49:03 +02:00
Timo 2d20af7199 fix(website): add missing navbar home bilingual translation on join page 2026-05-18 20:43:56 +02:00
Timo 579677e11c feat(website): overhaul interactive extension mockup alignment and update server docker/caddy examples 2026-05-18 20:43:02 +02:00
Timo 05e1801653 feat(website): fully redesign website and invitation page with premium responsive layouts, inline SVG icons, and a sleek vector Globe language selector 2026-05-18 20:31:18 +02:00
Timo 6774b2bfb7 feat(website): fix Chrome icon, add dynamic Firefox targeting and update versioning guidelines 2026-05-18 20:15:10 +02:00
GitHub Action 108d015e9f chore(release): update versions to v1.5.3 [skip ci] 2026-05-18 18:10:54 +00:00
Timo e0b9e9ef27 perf(background): reduce reactive update lock duration to 1.0s 2026-05-18 20:10:34 +02:00
GitHub Action 641c9bfb24 chore(release): update versions to v1.5.2 [skip ci] 2026-05-18 18:09:45 +00:00
Timo cf993b4ef0 fix(background): reset reactive locks on execute force sync for instant play update 2026-05-18 20:09:20 +02:00
Timo 5b1d6d7ba4 docs: add mandatory node -c syntax check before committing/pushing 2026-05-18 19:45:01 +02:00
GitHub Action 9ed7c98933 chore(release): update versions to v1.5.1 [skip ci] 2026-05-18 17:44:37 +00:00
Timo 68b0f3306c fix(background): resolve missing closing brace syntax error in alarms onAlarm listener 2026-05-18 19:44:12 +02:00
GitHub Action a71cdc3e53 chore(release): update versions to v1.5.0 [skip ci] 2026-05-18 17:41:13 +00:00
267 changed files with 36596 additions and 1546 deletions
+2
View File
@@ -0,0 +1,2 @@
# These are supported funding model platforms
ko_fi: koaladev
+56
View File
@@ -0,0 +1,56 @@
---
name: 🐛 Bug Report
about: Create a report to help us improve KoalaSync
title: ''
labels: bug
assignees: ''
---
> **⚠️ Required:** Before submitting, open the KoalaSync **Status** tab in the extension popup and click **"Copy Logs"**. Paste the full output below — it contains essential system info, connection state, and debug data needed to diagnose your issue.
<details>
<summary><b>📋 Copy Logs Output</b></summary>
<!-- Paste the copied logs here -->
</details>
---
### Describe the Bug
A clear and concise description of what the bug is.
### To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
### Expected Behavior
A clear and concise description of what you expected to happen.
### Actual Behavior
A clear and concise description of what actually happened.
### Screenshots / Screen Recordings
If applicable, add screenshots or recordings to help explain your problem.
### Environment
- **Browser:** (e.g. Chrome 125, Firefox 128)
- **Extension Version:** (visible at the bottom of the Settings tab)
- **OS:** (e.g. Windows 11, macOS 14.5)
- **Self-Hosted Server?:** (yes / no — if yes, provide server version)
- **Website/Platform:** (e.g. YouTube, Netflix, Twitch, Jellyfin, Emby)
### Additional Context
Add any other context about the problem here (e.g. network setup, VPN usage, multiple monitors, etc.).
+31
View File
@@ -0,0 +1,31 @@
---
name: 🚀 Feature Request
about: Suggest a new feature or enhancement for KoalaSync
title: ''
labels: enhancement
assignees: ''
---
### Problem Description
A clear and concise description of the problem or limitation you're encountering. What's missing or what could be improved?
*As a user, I want to ... so that ...*
### Proposed Solution
Describe the feature you'd like to see. How should it work? Be as specific as possible.
### Use Cases & Benefits
- **When would you use this?** (e.g., specific websites, workflows, setups)
- **What value does it add?** (e.g., saves time, enables new functionality, improves UX)
### Alternatives Considered
What workarounds or alternative approaches have you tried? Are there other ways to achieve a similar result?
### Additional Context
Add any other context, sketches, mockups, or references (e.g., links to similar features in other projects).
+45
View File
@@ -0,0 +1,45 @@
<!--
Thanks for contributing to KoalaSync!
Please read the CONTRIBUTING.md and CODE_OF_CONDUCT.md before submitting.
By submitting this PR, you agree to abide by our code of conduct.
Use conventional commits for the title: feat:, fix:, docs:, refactor:, chore:
-->
### Description
<!-- What does this PR do and why? Link to relevant issue or motivation. -->
Closes #
### Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that alters existing behavior)
- [ ] Refactoring (no functional changes)
- [ ] Documentation update
- [ ] Build, dependencies, or CI
### How Has This Been Tested?
<!-- Describe the tests you ran and the environments (browsers, OS, etc.) -->
- [ ] Tested on Chrome
- [ ] Tested on Firefox
- [ ] `npm run lint` passes with zero errors and zero warnings
- [ ] `node -c` passes on all modified `.js` files
### Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have added/updated tests if needed
- [ ] I have updated documentation if needed (`docs/`, README, etc.)
- [ ] Protocol changes: I ran `node scripts/build-extension.js` and updated relevant docs
- [ ] No new warnings, secrets, or hardcoded credentials introduced
### Additional Context
<!-- Screenshots, migration notes, performance data, etc. -->
+131
View File
@@ -0,0 +1,131 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Project maintainers have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainer at **koaladev@koalamail.rocks**.
All complaints will be reviewed and investigated promptly and fairly.
All project maintainers are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Project maintainers will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from project maintainers, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+141 -43
View File
@@ -1,58 +1,156 @@
# Contributing to KoalaSync
Thank you for your interest in contributing to KoalaSync! We welcome all contributions, from bug reports to new features.
Thanks for your interest in improving KoalaSync. All contributions are welcome — from bug reports and translations to core protocol changes.
## Development Workflow
Please note that by participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
### 1. Prerequisites
- Node.js (v18+)
- Docker (for local server testing)
---
### 2. Setup
1. Clone the repository.
2. Run `npm install` in the root directory to install build dependencies.
3. Run the build script to synchronize protocol constants and generate browser bundles:
```bash
node scripts/build-extension.js
```
## Ways to Contribute
### 3. Testing Locally
1. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (`chrome://extensions/` → Developer Mode → Load Unpacked).
2. For Firefox, load `dist/firefox/` via `about:debugging` → "Load Temporary Add-on".
3. Start the relay server: `docker-compose up --build`.
4. Use **two different browser profiles** (or Chrome + Firefox) to test multi-peer synchronization.
5. Use the extension's **Dev tab** to verify real-time video element metadata (`readyState`, `currentTime`, `paused`).
| Area | Description |
|------|-------------|
| **Bug Reports** | Found a bug? Open an issue with repro steps (see template below). |
| **Code** | Fix bugs, add features, or improve the extension / server / website. |
| **Translations** | Help localize the extension and website into more languages. See [TRANSLATION.md](website/TRANSLATION.md). |
| **Documentation** | Improve docs, fix typos, or add missing examples. |
| **Security** | Found a vulnerability? See [SECURITY.md](SECURITY.md) — do NOT open a public issue. |
### 4. Protocol Synchronization
KoalaSync uses a "Single Source of Truth" for protocol constants in `shared/constants.js`.
- **CRITICAL**: If you modify the constants, you MUST run the build script:
```bash
node scripts/build-extension.js
```
This will automatically synchronize the changes to the extension and generate the browser-specific bundles in the `dist/` folder.
---
### 5. Code Standards
- **Vanilla JS**: The extension must remain dependency-free. Do not add npm packages to the `extension/` directory.
- **Privacy**: Do not add external requests (CDNs, fonts, analytics, etc.).
- **Comments**: Maintain the existing documentation style, especially for complex sync logic.
- **Room IDs**: Room IDs are restricted to `[a-zA-Z0-9-]` (alphanumeric + hyphens only). Ensure any UI that generates room IDs follows this constraint.
## Development Setup
### Prerequisites
- **Node.js** v18+
- **Docker** (for local relay server testing)
### Quick Start
```bash
git clone https://github.com/Shik3i/KoalaSync.git
cd KoalaSync
npm install
node scripts/build-extension.js
```
---
## Project Structure
| Directory | Purpose |
|-----------|---------|
| `extension/` | Browser extension (Manifest V3, Chrome & Firefox) |
| `server/` | Node.js + Socket.IO relay server (Dockerized) |
| `website/` | Landing page, invitation bridge, and marketing site |
| `shared/` | Protocol constants — single source of truth |
| `scripts/` | Build and sync utilities |
| `docs/` | Architecture, sync protocol, and deep-dive guides |
---
## Testing Locally
### Extension
1. Load `dist/chrome/` as an unpacked extension in Chrome (`chrome://extensions/` → Developer Mode → **Load unpacked**).
2. For Firefox: load `dist/firefox/` via `about:debugging`**Load Temporary Add-on**.
3. Start the relay server: `docker compose up --build`.
4. Use **two different browser profiles** (or Chrome + Firefox) to test multi-peer sync.
5. Use the extension's **Dev tab** to inspect real-time video element state (`readyState`, `currentTime`, `paused`).
### Website
```bash
node website/build.js # Compile static site → www/
python3 -m http.server 8080 -d website/www # Serve locally
```
Then open `http://localhost:8080`. For multi-language testing: `http://localhost:8080/de/`.
---
## Protocol Constants
KoalaSync uses a **single source of truth** for all protocol constants in `shared/constants.js`.
### 6. Version Numbers
> [!IMPORTANT]
> **Do NOT manually bump version numbers.** The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manually changing version numbers in a PR will cause conflicts.
> After modifying `shared/constants.js`, you **must** run the build script to sync changes to the extension:
> ```bash
> node scripts/build-extension.js
> ```
> This automatically injects constants into `content.js` and regenerates browser bundles in `dist/`.
---
## Code Standards
- **Vanilla JS**: The extension must remain dependency-free. No npm packages in `extension/`.
- **Privacy-first**: Zero external requests — no CDNs, fonts, analytics, or trackers. All assets self-hosted.
- **System font stack**: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ...` — never `@import` external fonts.
- **Room IDs**: Restricted to `[a-zA-Z0-9-]` (alphanumeric + hyphens only). Enforced server-side.
- **Comments**: Document complex sync logic. The codebase uses inline comments for protocol reasoning.
---
## Version Numbers
> [!CAUTION]
> **Never manually bump version numbers.** The CI pipeline injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manual bumps cause conflicts.
---
## Pull Request Process
1. Create a new branch for your feature or bugfix.
2. Ensure your code is tested locally (Chrome and Firefox).
3. Update relevant documentation (e.g., `docs/ARCHITECTURE.md` if you change the protocol).
4. Submit your PR with a clear description of the changes.
## Bug Reports
When reporting a bug, please include:
- **Browser**: Chrome / Firefox / Edge + version number.
- **Extension Version**: Visible in the popup's Dev tab.
- **Dev Tab Output**: Copy the connection status, logs, and video debug info from the Dev tab.
- **Steps to Reproduce**: A clear sequence of actions that triggers the issue.
1. **Branch** from `main` for your feature or fix.
2. **Test locally** on both Chrome and Firefox.
3. **Website changes**: Run `node website/build.js` and verify the compiled output in `www/`.
4. **Lint**: Ensure `npm run lint` passes with zero errors and zero warnings.
5. **Syntax**: Run `node -c` on every modified `.js` file.
6. **Protocol changes**: Update relevant documentation in `docs/`.
7. **Submit your PR** with a clear description and linked issue (if applicable).
---
## Bug Report Template
When filing a bug, the easiest way is to use the **Copy Logs** button in the extension's **Status** tab. It copies a fully formatted Markdown report to your clipboard containing:
- System info (version, protocol, peer ID, browser)
- Connection status (server, room, peers, reconnect state)
- Video debug info (playback state, readyState, network state, dimensions, error codes, shadow DOM detection, platform)
- Action history (last 20 events)
- Log entries (last 50)
Simply paste the clipboard contents into your GitHub issue and add:
| Field | Example |
|-------|---------|
| **Steps to Reproduce** | 1. Create room → 2. Join from second browser → 3. Play video |
| **Expected Behavior** | Both peers play simultaneously |
| **Actual Behavior** | Peer B remains paused |
If you cannot access the Status tab, include as much of the following manually:
| Field | Example |
|-------|---------|
| **Browser** | Chrome 125, Firefox 128 |
| **Extension Version** | v1.9.3 (visible at bottom of Settings tab) |
| **Website/Platform** | Netflix, YouTube, Twitch, Jellyfin, etc.
---
## Translation Contributions
KoalaSync supports 6 languages: English, German, French, Spanish, Portuguese (Brazilian), and Russian.
To add or improve translations:
1. Edit the locale files in `website/locales/` (for the website).
2. For extension translations, see [TRANSLATION.md](website/TRANSLATION.md).
3. Run `node website/build.js` to regenerate the static site.
---
## Security
If you find a security vulnerability, please do not open a public issue. Instead, refer to our [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
If you discover a security vulnerability, **do not open a public issue**. Report it privately as described in [SECURITY.md](SECURITY.md).
-37
View File
@@ -1,37 +0,0 @@
# KoalaSync - Production Caddy Configuration Example
# Replace domains and paths with your actual setup.
# 1. Marketing Website & Invitation Bridge
sync.koalastuff.net {
root * /var/www/koalasync/website
file_server
encode zstd gzip
# Security Headers
header {
# Prevent FLoC tracking
Permissions-Policy interest-cohort=()
# Security best practices
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer-when-downgrade
}
}
# 2. Relay Server (Socket.IO / WebSocket)
syncserver.koalastuff.net {
reverse_proxy localhost:3000 {
# Ensure WebSocket support is explicitly handled if needed
# (Caddy usually handles this automatically)
header_up Host {host}
header_up X-Real-IP {remote_host}
}
# Security Headers for the relay
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
}
}
+60 -22
View File
@@ -1,18 +1,25 @@
# <img src="extension/icons/icon128.png" width="32" valign="middle"> KoalaSync
<p align="center">
<img src="website/assets/PlatformJuggler_New.webp" width="280" alt="KoalaSync Mascot">
</p>
<h1 align="center">KoalaSync</h1>
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/github/v/release/Shik3i/KoalaSync" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Shik3i/KoalaSync?color=blue" alt="License"></a>
<img src="https://img.shields.io/badge/Browser-Chrome%20|%20Firefox-blueviolet" alt="Cross Browser">
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<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>
KoalaSync is a premium, lightweight Browser Extension and Relay Server for synchronized video playback across any website—YouTube, Twitch, Netflix, and custom HTML5 players. Built with a focus on **Data Sovereignty** and **Extreme Performance**.
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.2.0 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture.
* **📡 Direct Logic**: Custom wire protocol implementation for frame-perfect synchronization.
* **🛡️ 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.
@@ -24,12 +31,34 @@ KoalaSync is a premium, lightweight Browser Extension and Relay Server for synch
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together.
- **Smart Matching**: Automatically highlights tabs containing matching video titles.
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
- **Zero-Latency Relay**: Custom wire protocol implementation for maximum performance.
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
- **Seamless Invitations**: Smart links that automatically configure server and room credentials for your friends.
- **Audio Compressor**: Built-in Web Audio API dynamic range compression with presets (Recommended, Dynamic Range, Vocal Enhancement, Smooth) and fully customizable controls.
---
### 📂 Repository Structure
### 🚀 Quick Start
#### For Users (Installation & Usage)
The easiest and safest way to install KoalaSync is directly through the official browser stores:
<p>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white&style=for-the-badge" alt="Chrome Extension"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white&style=for-the-badge" alt="Firefox Add-on"></a>
</p>
*(For manual offline installation: Download the latest `.zip` from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page and load it as an "Unpacked Extension" in Developer Mode).*
**How to use:**
1. **Create a Room:** Click the Koala icon in your browser and hit `+ Create New Room`.
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! 🍿
---
### 🛠️ For Developers & Self-Hosters
#### 📂 Repository Structure
- `extension/`: Browser Extension (Chrome & Firefox).
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
@@ -38,16 +67,7 @@ KoalaSync is a premium, lightweight Browser Extension and Relay Server for synch
- `scripts/`: Automated build and synchronization utilities.
- `docs/`: Technical deep-dives ([Architecture](docs/ARCHITECTURE.md), [Sync Guide](docs/SYNC_GUIDE.md)).
---
### 🚀 Quick Start
#### For Users (Installation)
The easiest way to install KoalaSync is to download the pre-compiled version from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page.
1. Download the latest `koalasync-chrome.zip` or `koalasync-firefox.zip`.
2. Extract the file and load it as an "Unpacked Extension" in your browser's Developer Mode.
#### For Developers (Building)
#### Building from Source
To build the extension from source and synchronize protocol constants:
```bash
npm install
@@ -62,28 +82,46 @@ 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.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-compose.example.yml](docker-compose.example.yml) for advanced configuration.
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"}`.
---
### 🌐 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.
---
### 📖 Documentation & Links
- **[PRIVACY.md](PRIVACY.md)**: Data Handling and Privacy Policy.
- **[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](docs/PRIVACY.md)**: Data Handling and Privacy Policy.
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: How to help make KoalaSync better.
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
- **[Caddyfile.example](Caddyfile.example)**: Production Caddy configuration for website and relay.
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
---
<div align="center">
<sub>Built with ❤️ by <a href="https://github.com/Shik3i">Shik3i</a>. KoalaSync is Open Source under the <a href="LICENSE">MIT License</a>.</sub>
<sub><a href="https://ko-fi.com/koaladev"><img src="https://img.shields.io/badge/Ko--Fi-Support-FF5E5B?logo=ko-fi&logoColor=white" alt="Ko-Fi"></a></sub>
<sub><a href="https://gitgem.org/github/Shik3i/KoalaSync"><img src="https://gitgem.org/api/badge/github/Shik3i/KoalaSync.svg" alt="GitGem Badge" /></a></sub>
</div>
+86 -13
View File
@@ -1,23 +1,96 @@
# Security Policy
KoalaSync is built on a **zero-persistence, privacy-first** architecture. We take security seriously and appreciate responsible disclosure of vulnerabilities.
---
## Supported Versions
We take the security of our users and their data very seriously. We actively support and patch the latest stable releases of KoalaSync.
Only the latest stable release receives security patches.
| Version | Supported |
| -------------- | ------------------ |
| Latest Release | :white_check_mark: |
| Older Versions | :x: |
| Version | Supported |
|---------|-----------|
| Latest release | :white_check_mark: Active |
| Older versions | :x: Unsupported |
Users on older versions are encouraged to update. The server enforces a minimum client version via `MIN_VERSION`.
---
## Scope
The following components are within scope for security reports:
| Component | Examples |
|-----------|----------|
| **Relay Server** (`server/`) | Authentication bypass, rate-limit evasion, room hijacking, DoS vectors |
| **Browser Extension** (`extension/`) | XSS via content scripts, privilege escalation, data exfiltration, tab snooping |
| **WebSocket Protocol** | Message injection, replay attacks, man-in-the-middle (WSS bypass) |
| **Website** (`website/`) | XSS, CSP bypass, invitation-hash leaks |
### Out of Scope
- Theoretical attacks requiring physical device access
- Social engineering or phishing
- Denial of service via resource exhaustion on self-hosted instances
- Vulnerabilities in third-party browser extensions or websites
---
## Reporting a Vulnerability
If you discover a security vulnerability within KoalaSync (e.g., related to the Node.js relay server, WebSocket wire protocol, or the Chrome/Firefox browser extension), please **DO NOT** report it by creating a public GitHub issue.
> [!CAUTION]
> **Do NOT open a public GitHub issue for security vulnerabilities.** Public disclosure before a patch is available puts users at risk.
Publicly disclosing a vulnerability before a patch is available puts our users at risk. Instead, please send an email privately to the project administrator at:
**koalasync_admin@koalamail.rocks**
Instead, email the project maintainer privately:
### What to expect
1. **Acknowledgment**: You should receive an acknowledgment of your report within 48 hours.
2. **Investigation**: We will investigate the issue, confirm its severity, and work on a patch.
3. **Resolution**: We will notify you when the patch is deployed to the Chrome Web Store, Mozilla Add-on Store, and our GitHub Docker releases.
4. **Disclosure**: Once the fix is confirmed and users have had time to update, we will publicly acknowledge your contribution in our release notes (unless you prefer to remain anonymous).
**`koalasync_admin@koalamail.rocks`**
Encrypt sensitive findings with our PGP key (available on request).
### What to Include
- **Affected component**: Server / Extension / Website / Protocol
- **Steps to reproduce**: Clear, minimal steps to trigger the vulnerability
- **Impact**: What an attacker could achieve (data access, privilege escalation, etc.)
- **Environment**: Browser version, extension version, server configuration
- **Suggested fix** (optional): If you have ideas for a patch
### What to Expect
| Timeline | Action |
|----------|--------|
| **Within 48 hours** | Acknowledgment of your report |
| **Within 7 days** | Initial assessment and severity confirmation |
| **As needed** | Collaborative discussion for clarification |
| **After patch** | Notification that the fix is deployed |
| **After rollout** | Public acknowledgment in release notes (or anonymity if preferred) |
---
## Architecture & Threat Model
KoalaSync's security is grounded in its architecture:
- **RAM-only relay**: No database, no persistent logs. All session data evaporates on disconnect.
- **Keyed SHA-256 room password hashes**: Plaintext passwords are never stored. Room passwords are held only as in-memory HMAC-SHA256 hashes for the short room lifetime, with brute-force protection: 5 attempts → 15-minute IP lockout.
- **Rate limiting**: Connection rate (IP-based, 60s window), health endpoint rate (10 requests/minute/IP), wrong admin-metrics bearer attempts (5 requests/minute/IP), and event rate (per-socket, 10s window). Health-style JSON responses are cached server-side for 60 seconds and refreshed lazily on request.
- **Reverse proxy boundary**: The relay trusts one proxy hop for client IP detection. In production, keep the Node server reachable only through Caddy or another trusted reverse proxy.
- **URL-hash credential isolation**: Invitation credentials live in the URL fragment (`#join:...`) — never sent to the web server.
- **Strict CSP**: `default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'`.
- **No third-party requests**: Zero CDNs, fonts, analytics, or external scripts.
If you find a way to bypass any of these protections, we want to know about it.
---
## Responsible Disclosure
We follow the principle of **coordinated vulnerability disclosure**:
1. You report privately.
2. We investigate and develop a patch.
3. We deploy to the Chrome Web Store, Firefox Add-ons, and Docker registry.
4. We credit you publicly (unless you prefer to remain anonymous).
We do not pursue legal action against researchers who act in good faith and follow this disclosure process.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

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

+37
View File
@@ -0,0 +1,37 @@
KoalaSync: The Privacy-First Watch Party Extension
Private watch parties for Emby, Jellyfin, Plex, Netflix, YouTube & more. No account needed. Real-time sync. Supports 13 languages. 100% free.
Whether you're binge-watching Netflix, streaming from your own self-hosted Emby, Jellyfin, or Plex servers, or watching on virtually any other website with a video player — KoalaSync keeps everyone perfectly in sync.
Lightweight, open-source, and built for absolute data sovereignty with a clean, minimalist UI.
✨ CORE FEATURES
• No Account Needed: Just create a room and share the link. No emails, no passwords, no hassle. A random nickname is assigned automatically, or pick your own.
• Universal HTML5 Sync: Flawlessly synchronizes play, pause, and seeking on Netflix, Jellyfin, Emby, YouTube, and almost any website hosting a standard HTML5 <video> tag.
• Smart Binge-Watching (Auto-Sync): When a new episode loads, KoalaSync automatically pauses playback in a smart lobby until everyone's video is ready. No spoilers, no one left behind.
• Fully Localized: Enjoy a native experience with a user interface fully translated into 13 different languages.
• One-Click Invitations: Generate a smart invite link. Once clicked, the extension instantly configures room credentials and server settings for your guests.
• Zero-Latency Protocol: Powered by a custom WebSocket architecture designed for real-time coordination without buffering loops.
🛡️ UNCOMPROMISING PRIVACY & SECURITY
No data collection. No compromises.
• 100% Anonymous: Zero tracking, zero analytics, zero telemetry.
• Ready Out-Of-The-Box: No technical knowledge needed. Install and start watching immediately using the official public server.
• RAM-Only Public Server: The official relay server operates entirely in volatile RAM. No databases, no logs, no watch history — nothing persists. It also features built-in rate-limiting and brute-force protection to prevent room-id spamming.
• Self-Hostable: Take total control by deploying a private relay server in seconds via Docker. Optional, never required.
🚀 HOW IT WORKS
1. Install KoalaSync and click "Create Room" to spin up a secure lobby.
2. Copy the invite link and send it to your friends.
3. Open your favorite streaming site and set the video tab as the active target.
4. Press play — everyone stays perfectly in sync.
💻 OPEN SOURCE
KoalaSync was built by a solo developer who needed a fast, secure way to watch movies with friends — and builds tools worth using. The code is fully transparent under the MIT license: audit it, fork it, improve it. Found a bug or have a feature idea? Open an issue on GitHub. Contributions and code reviews are always welcome.
• Website: https://sync.koalastuff.net
• GitHub: https://github.com/Shik3i/KoalaSync
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

-25
View File
@@ -1,25 +0,0 @@
services:
koala-sync:
image: ghcr.io/shik3i/koalasync:latest
container_name: KoalaSync
restart: always
ports:
- "3000:3000"
environment:
- TZ=Europe/Berlin
- PORT=3000
- MIN_VERSION=1.0.0
- MAX_ROOMS=100
- MAX_PEERS_PER_ROOM=50
# KoalaSync uses in-memory storage for the relay,
# so no persistent database volume is required.
pids_limit: 2048
# Example for custom network (e.g., Unraid/Macvlan)
# networks:
# custom_network:
# ipv4_address: 192.168.1.XXX
# networks:
# custom_network:
# external: true
# name: br0
+49 -10
View File
@@ -5,6 +5,9 @@ Welcome to the KoalaSync project. This file is the primary entry point for any d
> [!IMPORTANT]
> **Privacy & Data Sovereignty**: KoalaSync follows a strict **Zero-External-Requests Policy**: The extension and website must not make requests to any third-party domains (Google Fonts, CDNs, etc.). All assets (fonts, icons, scripts) must be self-hosted or use system defaults.
> - **Font Stack**: Use a modern system font stack (e.g., -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif) to maintain a premium look without external dependencies. Prohibit the use of `@import` or `<link>` for external font services.
>
> [!IMPORTANT]
> **Caveman Communication Protocol**: To conserve context window and tokens, AI agents MUST communicate with the user in **caveman language** (broken, short German/English hybrid caveman style, e.g., "Ich Antigravity. Ich machen Git pull. Code fertig.") at all times. This rule is highly strict and must NOT be broken under any circumstances unless the user explicitly requests to drop it.
---
@@ -18,6 +21,8 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
- `extension/`: Browser Extension (Chrome & Firefox, Manifest V3). Contains background service worker, content scripts, and popup UI.
- `server/`: Node.js Relay Server using Socket.IO (WebSocket-only).
- `website/`: **Landing Page** & Invitation Bridge (Marketing, Tutorials, and Downloads).
- **`build.js`**: Zero-dependency static site compiler. Translates `template.html` + `locales/*.json``www/`. Also minifies CSS/JS automatically.
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.js` to regenerate. CSS/JS are output as `.min.*` files — a built-in cleanup step removes stale artifacts on each build.
- `shared/`: **Single Source of Truth** for protocol constants and event names.
- `scripts/`: Development utilities (e.g., `build-extension.js`).
- `docker-compose.yml`: Root-level orchestration for the relay server.
@@ -29,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
@@ -68,7 +73,7 @@ The following features are critical and must not be removed or fundamentally alt
- **Two-Phase Force Sync**: The `Prepare``ACK``Execute` flow ensures all peers are buffered before playback resumes.
- **Episode Auto-Sync**: Ensures series binges stay perfectly synced. A lobby initiates during title transitions, freezing peers until everyone is ready.
- **Dual Heartbeat**:
- **Background Heartbeat (30s)**: Ensures session persistence even without a video element.
- **Background Heartbeat (1m)**: Ensures session persistence even without a video element.
- **Content Heartbeat (15s)**: Transmits current video metadata (time, title).
- **Dead Peer Pruning**: Server "Reaper" disconnects peers after 5 minutes of total silence (no heartbeats or events).
- **Deduplication**: Server kills old sockets if a user re-joins with the same `peerId` to prevent ghosts.
@@ -85,6 +90,7 @@ The following features are critical and must not be removed or fundamentally alt
- **Server Transport**: Restricted to `websocket` only. Polling is disabled.
- **Docker Context**: The Docker build must run from the **Repo Root**.
- **Manifest Settings**: `run_at` must remain `document_idle`, and `all_frames` must remain `false`.
- **Strict Backward & Forward Compatibility (Store Delay Rule)**: Browser extensions are distributed through stores (e.g., Chrome Web Store, Firefox Add-ons) which can take up to 2 weeks to approve updates. Therefore, the server MUST NOT reject older extension clients unless a critical protocol version bump is explicitly authorized, and new extension versions MUST remain fully operational when connected to older servers (e.g., by silently falling back if a new feature is not supported). This is a core architectural requirement.
## 9. Security & Deployment
- **Tokens**: Security tokens are intentionally managed via `shared/constants.js` and server `.env`.
@@ -94,20 +100,53 @@ The following features are critical and must not be removed or fundamentally alt
## 10. Common Workflows
## CRITICAL: Git & Release Rules
- **NEVER** push, commit, tag, or release without explicit user instruction.
- **NEVER** retag or force-push without explicit instruction.
- **NEVER** create tags for documentation-only or README changes.
- **NEVER** run `git push`, `git tag`, `git commit` unless the user says "push", "commit", or "tag".
- Only the user decides when to commit, push, tag, or release.
- Ask before any git write operation.
### ⚠️ Pre-Session Git Sync (MANDATORY)
Before starting any task, committing, or pushing, you **MUST** run `git pull --rebase` to ensure your local branch is up-to-date with `origin/main`. CI pipelines and other agents may push commits concurrently. Skipping this step will cause merge conflicts and rejected pushes.
### Releasing a New Version (CRITICAL WORKFLOW FOR AI AGENTS)
> [!CAUTION]
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
> The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json`. You do NOT need to manually bump version numbers.
1. Commit all code changes and push to `main`.
2. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
3. The CI will extract the version from the tag (e.g., `v1.4.0``1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release.
4. Verify the release builds on GitHub Actions.
>
> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures.
> - **Website Versioning**: **NEVER** manually modify the version fallback strings in `website/index.html`. The website dynamically fetches the latest version and release date from `website/version.json` at runtime using `website/app.js`. Manual bumps in the HTML file are completely redundant and should be avoided.
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
2. Commit all verified code changes and push to `main`.
3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0``v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
- **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed.
4. The CI will extract the version from the tag (e.g., `v1.4.0``1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release.
5. Verify the release builds on GitHub Actions.
### 🚫 Force Push Policy
> [!CAUTION]
> **Force pushing (`git push --force` or `git push -f`) is FORBIDDEN without explicit user confirmation.**
> - If a push is rejected due to a non-fast-forward conflict, you **MUST** run `git pull --rebase` first.
> - If a force push is absolutely required (e.g., squashed history, amended commits), you **MUST** ask the user for explicit permission with a clear explanation of why it's necessary. Never force-push autonomously.
> - This applies to both branches (`main`) and **tags** (see Tag Immutability above). Force-pushing tags is doubly destructive. Never do it.
### Adding a Protocol Event
1. Add the event name to `shared/constants.js`.
2. Run the build script (`node scripts/build-extension.js`).
3. Implement the handler in `server/index.js` and `background.js`.
### Making Website Changes
1. Edit source files in `website/` (`template.html`, `style.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
2. Run the compiler: `node website/build.js`. This generates the multilingual pages in `www/` and minifies CSS/JS.
3. Verify the output: `node --check website/www/app.js && node --check website/www/lang-init.js`.
4. Test locally: `npx serve website/www` or `python3 -m http.server 8080 -d website/www`.
5. Commit both source changes and the updated `www/` output.
### Testing Locally
1. Run the build script: `node scripts/build-extension.js`.
2. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (or `dist/firefox/` in Firefox).
+14 -7
View File
@@ -11,18 +11,19 @@ This document describes the communication flows and internal logic of the KoalaS
- **Protocol Version**: Client must match the server's protocol (currently `1.0.0`).
3. Server responds with Engine.IO handshake (`0`) and the client joins the namespace (`40`).
- **Room Join**: Background emits `JOIN_ROOM` containing `roomId`, `password`, `peerId`, and `username`.
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers".
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers". Deduplication re-validates after acquiring the room creation lock to avoid kicking the wrong socket during concurrent joins.
## 2. Media Event Synchronization
When a user interacts with a video:
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element.
2. **Prevention of Loops**: Uses an `expectedEvents` Set to distinguish between user actions and programmatic actions. Expected events are consumed on match and expire via timeout.
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element, including videos inside Shadow DOM (YouTube, Netflix, etc.).
2. **Prevention of Loops**: Uses an `expectedEvents` Set to distinguish between user actions and programmatic actions. Expected events are consumed on match and expire via timeout. Timeout IDs are cleaned up immediately to prevent memory leaks.
3. **Reporting**: `content.js` sends a `CONTENT_EVENT` to `background.js`.
4. **Relay**: The Server forwards the event to all other peers in the room.
5. **Execution**: Remote peers receive the command and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
5. **Execution**: Remote peers receive the command via `SERVER_COMMAND` (which includes the original `senderId` for correct ACK routing) and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
6. **ACK Routing**: `content.js` echoes the `commandSenderId` back in `CMD_ACK`, ensuring the `EVENT_ACK` is routed to the correct initiating peer even when multiple commands arrive concurrently.
## 3. Two-Phase Force Sync
Ensures all peers are frame-perfect and buffered before resuming:
Ensures all peers are buffered and synchronized before resuming:
1. **Prepare**: Initiator sends `FORCE_SYNC_PREPARE` with the target timestamp.
2. **Buffer**: Peers seek and pause. Once buffered (`readyState >= 3`), they send a `FORCE_SYNC_ACK`. (Note: `content.js` limits polling to 8000ms).
3. **Execute**: Once the Initiator collects ACKs (or after an 8.5s timeout), they send `FORCE_SYNC_EXECUTE`.
@@ -44,6 +45,7 @@ To maintain a clean room state and eliminate "Ghost Peers":
- **Video Heartbeat (Content)**: Every 15 seconds, `content.js` sends current playback metadata (time, title, state) if a video is found.
- **Server Pruning**: The server runs a "Reaper" every 2 minutes. If a peer has sent **zero** activity (no events and no heartbeats) for 5 minutes, they are forcefully disconnected.
- **Immediate Cleanup**: Rooms are deleted instantly when the last peer leaves or disconnects.
- **Reconnect Strategy**: Aggressive backoff — 500ms base, 1.5x multiplier, capped at 5s. Max 20 attempts before marking as failed. Events are queued during disconnect and flushed after namespace rejoin.
> [!CAUTION]
> **Identity Rule**: Differentiate between `peerId` and `socket.id`. Use `socket.id` exclusively for ephemeral transport routing on the server. Use `peerId` exclusively for identity, state management, and room tracking across the stack.
@@ -54,8 +56,13 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
- **Storm Prevention**: When dispatching state updates in response to a new user joining (e.g., an active lobby state), ensure ONLY the initiator (or a designated leader) calls `emit()` to prevent $O(N)$ broadcast storms.
## 7. Security & Stability
- **Service Worker Lifecycle**: Uses `chrome.alarms` to prevent the Manifest V3 service worker from suspending while in an active room.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS.
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or simple DoS. Public health endpoints are limited to 10 requests/minute/IP and cached server-side for 60 seconds, wrong admin-metrics bearer attempts to 5 requests/minute/IP, and room discovery to one request every 10 seconds per socket. Real client IP is taken from the trusted reverse proxy hop, so the Node port must stay private behind Caddy or another trusted proxy.
- **Room Creation Lock**: Per-room mutex prevents race conditions when multiple peers join a new room simultaneously.
- **CORS**: Allows `chrome-extension://` origins for WebSocket fallback compatibility.
- **Message Buffer**: `maxHttpBufferSize` set to 4KB to accommodate large `JOIN_ROOM` payloads.
- **Process Guards**: `uncaughtException` and `unhandledRejection` handlers prevent silent server crashes.
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
+168
View File
@@ -0,0 +1,168 @@
# KoalaSync Changelog
All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.2.0] — 2026-06-08
### Added
- **Web Audio API Compressor**: Built-in audio dynamic range compression with four presets (Recommended, Dynamic Range, Vocal Enhancement, Smooth) and fully customizable sliders (threshold, ratio, knee, attack, release, makeup gain). Uses dry/wet crossfade (40ms linear ramp) to avoid clicks. Configured via the new Audio Options page accessible from the Settings tab.
- **Audio Options Page** (`audio-options.html`): Dedicated settings page with master toggle, compressor preset selector, real-time custom sliders, and equalizer placeholder. Dark theme matching the popup design.
- **Feature Hint System**: Generic `dismissedHints` array in sync storage for announcing new features. First hint highlights the Audio Options entry in Settings. Extensible for future features.
### Changed
- **Ko-Fi Support Links**: Static footer badges on the Settings and Status tabs linking to the developer's support page. README and website footer updated with Ko-Fi badge.
### Fixed
- **Portuguese (PT) locale**: Removed Italian contamination — "sincronizzazione" → "sincronização", "tempo reale" → "tempo real", "Link di Invito" → "Link de Convite", "Sair della Sala" → "Sair da Sala".
- **Korean locale**: Fixed broken character in `HOWTO_STEP_2_TEXT` (`클rip보드``클립보드`).
- **Website COMP_FEAT_6_KOALA**: Normalized from inconsistent "6 Languages" to "13 Languages" across all locale files (en, de, es, fr, pt-BR, ru).
- **Debug report showing wrong logs**: Fixed `logs.slice(-50)` and `history.slice(-20)` in the "Copy Debug Report" feature. Since `addLog()` and `addToHistory()` use `unshift` (inserting entries at index 0), the arrays are ordered newest-first. `slice(-N)` took the N **oldest** entries instead of the N **newest**. Changed to `slice(0, N).reverse()` to correctly include the most recent logs and display them chronologically.
---
## [v2.1.2] — 2026-06-06
### Fixed
- **Episode guard regex**: Fixed `isDifferentEpisode()` not detecting episode changes when the MediaSession title uses `Sxx:Exx` format (colon separator, as used by Jellyfin/Emby). The regex character class `[\s\-\.]` was replaced with `[^a-zA-Z0-9]` to match **any** non-alphanumeric separator between season and episode numbers, preventing play/pause/seek commands from a different episode leaking through and incorrectly manipulating a peer's playback.
- **Per-device storage isolation**: Migrated `username`, `roomId`, `password`, `serverUrl`, and `useCustomServer` from `chrome.storage.sync` (synced across Google account) to `chrome.storage.local` (per-device). This prevents the extension from automatically joining the same room with the same name on multiple devices. Existing user data is migrated silently on first run; all preferences (`filterNoise`, `autoSyncNextEpisode`, etc.) remain synced.
### Changed
- Added one-time migration fallback in `getSettings()` and popup `init()` to copy existing user settings from `storage.sync` to `storage.local` on first launch after the update.
---
## [v2.1.0] — 2026-06-04
### Added
- Added full translation support for 7 new languages to both the browser extension popup settings and landing website: Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`).
- Implemented robust, centralized browser system language detection mapping `pt-BR` to Brazilian Portuguese and other `pt` locales (like `pt-PT`) automatically to European Portuguese.
- Added flag emojis to language selector dropdowns in both the extension popup and landing/utility web pages for quicker visual identification.
- Added 181 translation keys parity validation suite checks for the new languages.
### Fixed & Hardened (Extension Audit)
- Guarded all website `localStorage` interactions to prevent initialization/join flow script failures on privacy-hardened or cookie-blocked browser configurations.
- Added robust validation null-guards to `chrome.runtime.onMessage` listeners across all extension scripts (`bridge.js`, `content.js`, `background.js`, `popup.js`) to reject unexpected runtime messages.
- Guarded CustomEvent payload destructuring in `bridge.js` to ensure stability when receiving third-party page events.
- Wrapped `video.currentTime` seeking adjustments during forced sync in content scripts with exception handling to absorb uninitialized video state DOMExceptions.
- Added payload validation guards on incoming Socket.IO events within the background script's event handlers to secure against malformed server updates.
- Prevented noisy browser console exceptions from context invalidation in target tabs by catching promise rejections on extension message dispatches.
### Performance
- Implemented in-memory language dictionary caching in the background script to completely avoid redundant extension package filesystem reads during translations.
---
## [v2.0.8] — 2026-06-03
### Fixed
- Fixed a bug where switching language inside the extension popup overwrote dynamic fields (such as active room ID, connection status, active server details, and video debug info) with default localized placeholder texts.
- Fixed a version reporting mismatch where the copied logs (debug reports) and connection handshake parameters incorrectly reported the hardcoded `1.9.0` version instead of the actual installed manifest version.
---
## [v2.0.7] — 2026-06-03
### Added
- Added a `DEBUG_LOGGING` environment variable to the relay server (defaulting to `"0"` / disabled) to prevent console spam from verbose connection (`CONN`), room activity (`ROOM`, `DEDUPE`), and `CORS` events under load. Critical logs like `SERVER`, `SECURITY`, `AUTH`, and `ERROR` remain enabled at all times.
---
## [v2.0.6] — 2026-06-03
### Performance & Security Hardening
- Optimized failed authentication attempts cache eviction algorithm to $O(1)$ by exploiting Javascript `Map` insertion-order properties. This completely removes the previous array copying and sorting bottleneck, neutralizing a potential main-thread blocking DoS vector under heavy brute-force password traffic.
---
## [v2.0.5] — 2026-06-03
### Security & Hardening
- Hardened extension room idle auto-leave detection to correctly recognize when the target tab's video heartbeat goes stale (e.g., after tab navigation or media closure).
- Exported cleaner graceful shutdown and lifecycle methods (`stopServerForTests`) from the relay server to prevent socket leaks and port-binding conflicts during verify checks.
### Added
- Added a validation step in `test-locales.js` to ensure the supported language list in `extension/i18n.js` is perfectly synchronized with the actual JSON translation files in the locales directory.
- Added a robust route verification test suite (`scripts/test-server-routes.mjs`) covering rate limit throttling, caching headers, and admin metrics access control.
---
## [v2.0.4] — 2026-06-03
### Security & Hardening
- Hardened relay health endpoints against simple flood traffic: `GET /` and `GET /health` are now limited to 10 requests per minute per client IP.
- Added lazy 60-second server-side caching for `GET /`, basic `/health`, and admin `/health` JSON responses to reduce repeated health-check work under noisy polling.
- Added stricter brute-force throttling for invalid admin metrics bearer attempts.
- Added startup warning for short `ADMIN_METRICS_TOKEN` values and documented that production Node ports must stay private behind Caddy or another trusted reverse proxy.
- Lowered the default maximum peers per room to 25.
### Added
- Optional privacy-preserving admin metrics on `/health` when `ADMIN_METRICS_TOKEN` is configured and a valid bearer token is supplied. Metrics are aggregate-only and exclude room IDs, peer IDs, usernames, IP addresses, media titles, passwords, and other user-level data.
### Changed
- Removed `bcryptjs`; temporary room passwords continue to use keyed SHA-256/HMAC hashing as documented.
- Public room discovery is now rate-limited server-side to one refresh every 10 seconds per socket, with the extension refresh button locked for 11 seconds.
### Fixed
- Improved Shadow DOM video detection so real embedded players are not hidden by smaller light-DOM preview or placeholder videos.
- Fixed join-button timeout cleanup after join status responses.
---
## [v2.0.2] — 2026-06-02
### Fixed
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
---
## [v2.0.1] — 2026-06-01
### Fixed
- Video detection on Prime Video: `findVideo()` now scores all video elements by size, duration, and mute state instead of picking the first one. Fixes 0×0 placeholder being selected over the actual player.
- History entries in debug report showing `?` instead of action names.
- Prime Video status in compatibility matrix updated to reflect partial support.
### Added
- Multi-video overview table in Copy Debug Report when a page has more than one `<video>` element. Shows resolution, mute state, playback state, readyState, duration, and marks the currently targeted video.
---
## [v2.0.0] — 2026-06-01
### 🌍 Multi-Language Extension (Biggest Feature!)
- **6-Language UI**: The browser extension is now fully translated into **English, German, French, Spanish, Portuguese (Brazilian), and Russian**. Switch languages instantly in Settings without reload.
- **Real-Time i18n**: Every label, button, tooltip, toast notification, empty state, and onboarding guide updates dynamically when the language changes.
### New Features
- **Copy Debug Report (Markdown)**: The *Copy Logs* button in the Status tab now copies a fully formatted Markdown debug report — system info, connection status, video diagnostics, action history, and logs. One click, paste into a GitHub issue, all debugging data ready.
- **Platform Auto-Detection**: The Dev tab now identifies streaming platforms (YouTube, Netflix, Twitch, Prime Video, Disney+, HBO Max, Vimeo, Dailymotion) and displays the detected platform.
- **Enhanced Video Debug Info**: 20+ new fields in the Status tab including network state, buffered ranges, dimensions (with 0×0 warning), media error codes, shadow DOM status, seeking/ended/loop flags, volume, playback speed, and data attributes.
- **No-Video Diagnostic Mode**: When no video is found, the Status tab shows platform, page title, video count, shadow DOM presence, and MediaSession data to help troubleshoot.
### Changed
- **New TwoPointZero Branding**: Updated extension icons (16/32/48/96/128px).
- **Larger Popup Logo**: Extension popup icon increased to 48px.
- **Prime Video Unblocked**: Removed `amazon.` from the tab blacklist so Amazon/Prime Video tabs appear in the video selector.
- **Improved Debug Report**: Full User-Agent string for accurate browser identification, UTC timestamp, connection details including server URL and room info.
- **Smart Disconnect**: Improved disconnect handling when leaving rooms.
- **Human-Readable Room IDs**: Expanded word lists for friendlier room names.
- **Custom Server Support**: WEB_JOIN_REQUEST and join button for custom server invite flows.
- **Reconnection Strategy**: Custom server reconnection improvements.
- **Episode-Aware Sync**: Command sequencing with smarter episode transition detection and echo suppression for smoother series binges.
- **Sync Status Refinements**: YouTube and Twitch sync behavior improved.
- **No External Dependencies**: Extension remains dependency-free with no library overhead.
### Fixed
- Hardcoded strings, missing translation keys, and Service Worker notification race conditions.
---
## Versioning Policy
- **MAJOR** (x.0.0): Breaking protocol changes, architecture rewrites, or major feature milestones.
- **MINOR** (0.x.0): New features, significant enhancements, new translations, or UI redesigns.
- **PATCH** (0.0.x): Bug fixes, minor improvements, and documentation updates. PATCH releases may not receive individual changelog entries if bundled with a MINOR release.
+6 -6
View File
@@ -10,7 +10,7 @@ This guide walks through the complete user flow of KoalaSync, from creating a ro
2. The extension adds a small icon to your browser toolbar.
3. On first install, a unique 8-character **Peer ID** is generated locally and stored in `chrome.storage.local`. This ID is never sent to any external service — it only travels to the relay server when you join a room.
> **What's stored locally**: `peerId` (8-char hex), `username` (customizable), `serverUrl`, `filterNoise` preference. All stored via `chrome.storage.sync` and `chrome.storage.local`.
> **What's stored locally**: `peerId` (8-char hex), `username` (customizable, defaults to a readable adjective-noun pair), `serverUrl`, `filterNoise` preference. All stored via `chrome.storage.sync` and `chrome.storage.local`.
---
@@ -51,7 +51,7 @@ Click **"Create Room"** in the popup's Room tab:
4. **Server-side processing**:
- All fields are **sanitized**: `roomId` is stripped of invalid characters and clamped to 64 chars; `peerId` clamped to 16 chars; `password` clamped to 128 chars; `username` clamped to 30 chars.
- The server **hashes the password** with bcrypt and stores the hash in RAM (the plaintext is never stored).
- The server **hashes the password** with a keyed SHA-256 HMAC and stores only that hash in RAM (the plaintext is never stored).
- A new room object is created in memory with the peer's data.
- The server responds with `ROOM_DATA` containing the list of peers in the room.
@@ -89,7 +89,7 @@ When your friend opens the link in their browser:
- After 500ms, the page dispatches a `KOALASYNC_JOIN_REQUEST` custom DOM event with `{ roomId, password, useCustomServer, serverUrl }`.
- `bridge.js` catches this event and forwards it to `background.js` via `chrome.runtime.sendMessage`.
- `background.js` stores the credentials in `chrome.storage.sync` and emits `JOIN_ROOM` to the server.
- The server validates the password against the stored bcrypt hash.
- The server validates the password against the stored keyed SHA-256 HMAC hash.
- On success, the server responds with `ROOM_DATA` and broadcasts `PEER_STATUS { status: 'joined' }` to all existing peers.
- The join page updates to show "✅ Successfully joined!".
@@ -109,7 +109,7 @@ Both users now need to select which browser tab contains the video to sync:
4. Tabs with a **matching video title** are highlighted with a ⭐ prefix for easy identification.
5. Selecting a tab causes `background.js` to set `currentTabId` and inject `content.js` into that tab via `chrome.scripting.executeScript`.
> **What `content.js` does on injection**: Finds the first `<video>` element on the page and attaches event listeners for `play`, `pause`, `seeked`, `timeupdate`, and `volumechange`. It uses an `expectedEvents` Set to distinguish between user actions and programmatic actions (loop prevention).
> **What `content.js` does on injection**: Finds the first `<video>` element on the page and attaches event listeners for `play`, `pause`, `seeked`, and `loadeddata`. (Time and volume state are tracked via a 15-second heartbeat interval, not continuous event listeners). It uses an `expectedEvents` Set to distinguish between user actions and programmatic actions (loop prevention).
---
@@ -143,7 +143,7 @@ If videos drift out of sync, either user can click **"Force Sync"**:
### Phase 2 — Execute
6. Once all ACKs are received (or after 8.5 seconds), the initiator emits `FORCE_SYNC_EXECUTE`.
7. All peers call `video.play()` simultaneously, achieving frame-perfect sync.
7. All peers call `video.play()` simultaneously, achieving synchronized playback.
> **Why two phases?** Without buffering confirmation, peers with slower connections would start playing before they've loaded the target timestamp, causing immediate desync.
@@ -155,7 +155,7 @@ While in a room, two heartbeats keep the session alive:
| Heartbeat | Interval | Source | Purpose |
|:----------|:---------|:-------|:--------|
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" even without a video |
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" and triggers aggressive reconnect (500ms base, max 5s) |
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, `mediaTitle`, `playbackState`, `volume`, `muted` |
- **Server Reaper**: Every 2 minutes, the server checks for peers with no activity for 5+ minutes and disconnects them ("dead peer pruning").
+4 -3
View File
@@ -2,12 +2,14 @@
**KoalaSync does not collect, store, or sell any personal data.**
*We don't track you. We only track our server* (relying exclusively on aggregated, anonymous, and non-personal system metrics to monitor performance and stability).
KoalaSync is designed with a **Security-First & Volatile** architecture. This means we prioritize keeping your data out of persistent storage, though certain technical data must be processed temporarily to ensure service stability and security.
## 1. Data Processing (In-Memory Only)
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
- **Room Passwords**: If you set a room password, it is stored only as a secure **bcrypt hash** in RAM. The server never sees or stores your plaintext password.
- **Room Passwords**: If you set a room password, it is stored only as an in-memory **keyed SHA-256 HMAC hash**. The server receives the plaintext password only during join validation, never stores it, and keeps only the hash for the short room lifetime.
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
### Data Retention
@@ -15,8 +17,7 @@ KoalaSync does not use a database. All active session data exists only in the se
|:----------|:------------------|:---------------------|
| Session data (peerId, username, video metadata) | Duration of session | User leaves room or disconnects |
| Room state | 2 hours max | Last peer leaves, or inactivity timeout |
| Failed auth lockout records | 15 minutes | Automatic expiry |
| Auth failure records | 1 hour | Periodic cleanup |
| Auth failure records (lockout after 5 failed attempts) | 15 minutes | Periodic cleanup |
| Connection rate-limit counters | 60 seconds | Automatic expiry |
| Event rate-limit counters | 10 seconds | Automatic expiry + periodic cleanup |
+14
View File
@@ -0,0 +1,14 @@
# KoalaSync Roadmap
This document tracks future technical plans and optimizations for the KoalaSync system.
---
## Planned Optimizations & Technical Roadmap
### 1. Split large JavaScript files (> 800 lines) into smaller modules
* **Category**: Maintainability / AI Context Optimization
* **Background**: Core files like `background.js` and `popup.js` have grown large and exceed 800 lines. This makes manual debugging harder and wastes context window space for AI models.
* **Planned solution**:
- Structurally split logic into separate focused modules (e.g., UI Renderer, Message Router, Storage Manager, Socket Client).
- Use ES modules for clean separation and better reusability.
+1 -1
View File
@@ -31,7 +31,7 @@ The build script performs the following actions:
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
- The version is defined in `shared/constants.js`.
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
- **Always run the build script** after bumping the version number to ensure both components are updated.
- **Never manually bump version numbers**. The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Run the build script to synchronize other constant updates.
> [!CAUTION]
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
+46
View File
@@ -0,0 +1,46 @@
# Tested Streaming Services
This document tracks which streaming platforms and media servers have been tested with KoalaSync.
| 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. |
| **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. |
## Legend
| Symbol | Meaning |
|--------|---------|
| ✅ Full | Works without limitations. |
| ⚠️ Partial | Works with caveats (see Notes). |
| ❌ N/A | Not applicable or not supported. |
## How to Contribute
Tested a service that's not listed? Found different behavior than documented?
1. Test KoalaSync on the service with two browser profiles
2. Use the extension's **Dev tab** to check `readyState`, `currentTime`, and media title
3. Open a GitHub issue or PR updating this table
## Technical Background
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
- **Shadow DOM encapsulation** that hides video elements from content scripts
- **Strict Content Security Policies** (CSP) that block script injection
Websites with heavily obfuscated custom players (e.g., complex Shadow DOM, iframe isolation) may require platform-specific workarounds in `content.js`.
+71
View File
@@ -0,0 +1,71 @@
export default [
{
ignores: ["dist/**", "node_modules/**", "scratch/**"]
},
{
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
chrome: "readonly",
window: "readonly",
document: "readonly",
navigator: "readonly",
console: "readonly",
localStorage: "readonly",
setTimeout: "readonly",
setInterval: "readonly",
clearTimeout: "readonly",
clearInterval: "readonly",
fetch: "readonly",
CustomEvent: "readonly",
MutationObserver: "readonly",
IntersectionObserver: "readonly",
Uint32Array: "readonly",
Set: "readonly",
Map: "readonly",
Promise: "readonly",
Array: "readonly",
Object: "readonly",
JSON: "readonly",
Math: "readonly",
Number: "readonly",
String: "readonly",
Date: "readonly",
Error: "readonly",
URL: "readonly",
URLSearchParams: "readonly",
WebSocket: "readonly",
self: "readonly",
process: "readonly"
}
},
rules: {
"no-undef": "error",
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }],
"no-unreachable": "error",
"no-constant-condition": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-extra-semi": "error",
"no-prototype-builtins": "warn",
"no-unsafe-optional-chaining": "error",
"valid-typeof": "error"
}
},
{
files: ["server/**/*.js", "scripts/**/*.js", "website/build.js"],
languageOptions: {
globals: {
require: "readonly",
__dirname: "readonly",
__filename: "readonly",
process: "readonly",
module: "readonly",
exports: "readonly",
Buffer: "readonly"
}
}
}
];
+83
View File
@@ -0,0 +1,83 @@
# ==============================================================================
# KoalaSync - Production Caddy Configuration Example
# ==============================================================================
# This file provides examples of both a lightweight "Simple" configuration
# and a production-hardened "Advanced" configuration.
# Replace domains, reverse proxy locations, and directories with your actual setup.
# ------------------------------------------------------------------------------
# OPTION A: Simple Configuration
# ------------------------------------------------------------------------------
# Minimal configuration that serves the static website, enables gzip compression,
# supports extension-less Clean URLs, and reverse proxies the relay server.
# sync.koalastuff.net {
# root * /var/www/koalasync/website/www
# encode zstd gzip
#
# # Clean URLs support (resolves /join to join.html, etc.)
# try_files {path} {path}.html {path}/
# file_server
# }
#
# syncserver.koalastuff.net {
# reverse_proxy localhost:3000
# }
# ------------------------------------------------------------------------------
# OPTION B: Advanced Configuration (Production-Hardened)
# ------------------------------------------------------------------------------
# Highly secure, optimized configuration using advanced HTTP security headers,
# aggressive static assets caching, server signature concealment, and strict
# hardware permission access policies.
(security_headers) {
header {
# Enable HTTP Strict Transport Security (HSTS)
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Prevent clickjacking attacks (Sameorigin)
X-Frame-Options "SAMEORIGIN"
# Prevent MIME-sniffing
X-Content-Type-Options "nosniff"
# Enable browser XSS protection
X-XSS-Protection "1; mode=block"
# Control referrer information
Referrer-Policy "strict-origin-when-cross-origin"
# Hide Caddy server stamp signature
-Server
}
}
sync.koalastuff.net {
encode zstd gzip
root * /var/www/koalasync/website/www
# Clean URLs: Resolves paths without .html in the URL
try_files {path} {path}.html {path}/
file_server
# Static Caching for high-performance PageSpeed (1 year with validation)
@static {
file
path *.ico *.css *.js *.png *.svg *.webp *.avif
}
header @static Cache-Control "public, max-age=31536000, must-revalidate"
# Security Headers & Content Security Policy (CSP)
import security_headers
header {
# CSP hardened with base-uri and form-action limits
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none';"
# Modern Permissions Policy (blocks browser hardware access for enhanced privacy)
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
}
}
syncserver.koalastuff.net {
import security_headers
encode zstd gzip
reverse_proxy KoalaSync:3000
}
+18
View File
@@ -0,0 +1,18 @@
services: # Top-level key defining all containers in this Compose file
koala-sync: # Name of the KoalaSync service
image: ghcr.io/shik3i/koalasync:latest # Pulls the latest KoalaSync image from GitHub Container Registry
container_name: KoalaSync # Sets a fixed container name instead of an auto-generated one
restart: always # Always restart the container if it stops or if Docker starts
environment: # Environment variables passed into the container
- TZ=Europe/Berlin # Sets the timezone inside the container
- PORT=3000 # Port KoalaSync listens on inside the container
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
- MAX_ROOMS=100 # Maximum number of rooms that can exist
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
networks: # Attaches the service to the networks listed below
- caddy_net # Joins the pre-existing Caddy network for reverse proxying
networks: # Top-level networks definition
caddy_net: # Network name as referenced by the service
external: true # Marks the network as managed outside of Compose (created by Caddy)
+22
View File
@@ -0,0 +1,22 @@
services: # Top-level key defining all containers in this Compose file
koala-sync: # Name of the KoalaSync service
image: ghcr.io/shik3i/koalasync:latest # Pulls the latest KoalaSync image from GitHub Container Registry
container_name: KoalaSync # Sets a fixed container name instead of an auto-generated one
restart: always # Always restart the container if it stops or if Docker starts
ports: # Exposes the container port to the host
- "3000:3000" # Maps host port 3000 to container port 3000
environment: # Environment variables passed into the container
- TZ=Europe/Berlin # Sets the timezone inside the container
- PORT=3000 # Port KoalaSync listens on inside the container
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
- MAX_ROOMS=100 # Maximum number of rooms that can exist
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
networks: # Attaches the service to the networks listed below
bond0_network: # Network name as referenced by the service
ipv4_address: 192.168.1.XXX # Static IPv4 address for the KoalaSync container
networks: # Top-level networks definition
bond0_network: # Network name inside Compose
external: true # Marks the network as managed outside of Compose
name: bond0 # Name of the pre-existing network on the Docker host
+79
View File
@@ -0,0 +1,79 @@
# Prometheus Community JSON Exporter Configuration Example
# File: examples/json_exporter.example.yml
#
# Use this configuration to map KoalaSync admin health metrics (JSON)
# to native Prometheus metrics.
#
# Usage:
# 1. Rename this file to json_exporter.yml
# 2. Replace "YOUR_ADMIN_METRICS_TOKEN" with your actual ADMIN_METRICS_TOKEN env value
# 3. Mount it to the json-exporter docker container: /config.yml
modules:
koalasync:
http_client_config:
bearer_token: "YOUR_ADMIN_METRICS_TOKEN"
metrics:
- name: koalasync_uptime_seconds
path: '{.uptime}'
help: "Uptime of the KoalaSync relay server in seconds"
- name: koalasync_rooms
path: '{.rooms}'
help: "Total active rooms"
- name: koalasync_connections
path: '{.connections}'
help: "Total active socket connections (sockets)"
- name: koalasync_peers
path: '{.peers}'
help: "Total connected peers across all rooms"
- name: koalasync_rooms_with_lobby
path: '{.roomsWithLobby}'
help: "Number of rooms waiting in an episode lobby"
- name: koalasync_avg_peers_per_room
path: '{.avgPeersPerRoom}'
help: "Average number of peers per room"
- name: koalasync_max_peers_in_room
path: '{.maxPeersInRoom}'
help: "Maximum number of peers in a single room"
- name: koalasync_memory_rss_bytes
path: '{.memory.rss}'
help: "Resident Set Size (RSS) memory usage in bytes"
- name: koalasync_memory_heap_used_bytes
path: '{.memory.heapUsed}'
help: "V8 engine heap used in bytes"
- name: koalasync_memory_heap_total_bytes
path: '{.memory.heapTotal}'
help: "V8 engine heap total in bytes"
- name: koalasync_rate_limit_connections
path: '{.rateLimitEntries.connections}'
help: "Number of entries tracked in the connection rate limiter"
- name: koalasync_rate_limit_events
path: '{.rateLimitEntries.events}'
help: "Number of entries in the event rate limiter"
- name: koalasync_rate_limit_health
path: '{.rateLimitEntries.health}'
help: "Number of entries in the health rate limiter"
- name: koalasync_rate_limit_admin_metrics_auth
path: '{.rateLimitEntries.adminMetricsAuth}'
help: "Number of entries in the admin metrics auth rate limiter"
- name: koalasync_rate_limit_auth_failures
path: '{.rateLimitEntries.authFailures}'
help: "Number of entries in the authentication failures cache"
- name: koalasync_rate_limit_room_list
path: '{.rateLimitEntries.roomList}'
help: "Number of entries in the room list cooldown cache"
+3 -2
View File
@@ -8,11 +8,12 @@ A Manifest V3 Browser Extension (Chrome & Firefox) for synchronized video playba
- **Smart Peer IDs**: Hexadecimal IDs combined with customizable Usernames for easy identification.
- **Dual Heartbeat**: Advanced session tracking (Background) and video synchronization (Content) to prevent ghost sessions.
- **Live Diagnostics**: Built-in "Dev" tab for real-time video state debugging (ReadyState, CurrentTime, etc.).
- **Dynamic i18n (Multi-Language)**: Fully localized in 6 languages (`en`, `de`, `fr`, `es`, `pt-BR`, `ru`) with auto-detected fallback and dynamic on-the-fly language selectors.
## Tab Overview
1. **Room**: Manage connections, view active peers, and share invitation links.
2. **Sync**: Control video playback (Play/Pause/Force Sync) and view recent activity.
3. **Settings**: Customize your Username and toggle domain-based Noise Filtering.
3. **Settings**: Customize your Username, toggle domain-based Noise Filtering, and switch the App Language.
4. **Dev**: Monitor connection status and view real-time video element metadata for debugging.
## Privacy & Permissions
@@ -20,7 +21,7 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
- **No Browsing History**: We do not track or store your browsing history.
- **State Management**: Sensitive data (Room Passwords) is stored locally using `chrome.storage`.
- **Zero Telemetry**: No analytics or external tracking scripts.
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring maximum performance and privacy.
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring performance and privacy.
## Installation
1. **Prepare Extension**: From the repository root, run:
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);
});
+726 -187
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -1,3 +1,4 @@
/* global cloneInto */
/**
* KoalaSync Bridge Script
* Injected into sync.koalastuff.net to facilitate communication between
@@ -9,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',
@@ -16,18 +18,22 @@ 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 event = new CustomEvent('KOALASYNC_STATUS', {
detail: {
success: msg.success,
message: msg.message
}
});
window.dispatchEvent(event);
const detail = { success: msg.success, message: msg.message };
// Firefox MV3 content scripts run in an isolated world. When dispatching
// a CustomEvent with a detail object, Firefox wraps it in an XrayWrapper
// that the page's JavaScript cannot destructure (Permission denied).
// cloneInto() exposes the object to the page's context correctly.
// Chrome doesn't have this issue — cloneInto() is undefined there.
const safeDetail = typeof cloneInto === 'function'
? cloneInto(detail, document.defaultView)
: detail;
window.dispatchEvent(new CustomEvent('KOALASYNC_STATUS', { detail: safeDetail }));
}
});
+542 -72
View File
@@ -9,7 +9,7 @@
if (window.koalaSyncInjected && chrome.runtime.id) {
return;
}
} catch (e) {
} catch (_e) {
// Context invalidated, proceed with re-injection
}
window.koalaSyncInjected = true;
@@ -29,39 +29,252 @@
};
// --- SHARED_EVENTS_INJECT_END ---
let expectedEvents = new Set();
let expectedTimeouts = {};
// Suppresses native event reporting after a programmatic action.
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
function _setSuppress(state) {
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
_suppressTimers[state] = setTimeout(() => {
delete _suppressTimers[state];
}, 300);
}
function _clearSuppress(state) {
if (_suppressTimers[state]) {
clearTimeout(_suppressTimers[state]);
delete _suppressTimers[state];
}
}
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
// from being relayed to peers as user-initiated seeks.
const MIN_SEEK_DELTA = 3.0;
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
// --- Episode Auto-Sync State ---
let lastKnownMediaTitle = null;
let episodeTransitionDebounce = null;
let pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
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;
function expectEvent(state) {
expectedEvents.add(state);
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
const timeout = state === 'seek' ? 10000 : 1500;
expectedTimeouts[state] = setTimeout(() => {
expectedEvents.delete(state);
}, timeout);
}
// Cache the autoSyncNextEpisode setting
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 === 'local' && changes.autoSyncNextEpisode) {
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
}
});
function reportLog(message, level = 'info') {
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
}
// --- Helper: find the best video element on the page ---
function findVideo() {
const videos = document.querySelectorAll('video');
return videos.length > 0 ? videos[0] : null;
// Prefers larger, visible videos over tiny preview/trailer elements.
function findVideo(root = document) {
const candidates = Array.from(root.querySelectorAll('video'));
// Scan likely media hosts even when light-DOM videos exist; many players
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
for (const el of potentialHosts) {
if (el.shadowRoot) {
const found = findVideo(el.shadowRoot);
if (found) candidates.push(found);
}
}
if (candidates.length === 0) return null;
// Multiple videos found → pick the best one
if (candidates.length === 1) return candidates[0];
let best = null;
let bestScore = -1;
for (const v of candidates) {
if (v.tagName !== 'VIDEO') continue;
// Score: visible area + bonus for unmuted + bonus for longer duration
const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0);
const unmutedBonus = v.muted ? 0 : 100000;
const durationBonus = (v.duration && isFinite(v.duration) ? v.duration : 0) * 100;
const score = area + unmutedBonus + durationBonus;
if (score > bestScore) {
bestScore = score;
best = v;
}
}
return best;
}
// --- Audio Processing Module ---
const AUDIO_PRESETS = {
recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 },
dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 },
vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 },
smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 },
custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 }
};
const DEFAULT_AUDIO_SETTINGS = {
enabled: false,
compressor: {
enabled: false,
preset: 'recommended',
customParams: { ...AUDIO_PRESETS.custom }
}
};
let audioCtx = null;
let audioChains = new WeakMap();
let currentAudioVideo = null;
function mergeAudioSettings(settings = {}) {
const safeSettings = settings && typeof settings === 'object' ? settings : {};
return {
...DEFAULT_AUDIO_SETTINGS,
...safeSettings,
compressor: {
...DEFAULT_AUDIO_SETTINGS.compressor,
...(safeSettings.compressor || {}),
customParams: {
...DEFAULT_AUDIO_SETTINGS.compressor.customParams,
...(safeSettings.compressor?.customParams || {})
}
}
};
}
function initAudioContext() {
if (!audioCtx) {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
audioCtx = new AudioContextClass({ latencyHint: 'interactive' });
} catch (e) {
reportLog(`Audio Processing unavailable: ${e.message}`, 'warn');
return null;
}
}
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
}
return audioCtx;
}
function closeAudioContext() {
if (audioCtx) {
audioCtx.close().catch(() => {});
audioCtx = null;
}
audioChains = new WeakMap();
currentAudioVideo = null;
}
function setupAudioChain(videoEl) {
if (audioChains.has(videoEl)) return audioChains.get(videoEl);
const ctx = initAudioContext();
if (!ctx) return null;
try {
const src = ctx.createMediaElementSource(videoEl);
const compressor = ctx.createDynamicsCompressor();
const dryGain = ctx.createGain();
const compGain = ctx.createGain();
src.connect(dryGain);
dryGain.connect(ctx.destination);
src.connect(compressor);
compressor.connect(compGain);
compGain.connect(ctx.destination);
dryGain.gain.value = 1;
compGain.gain.value = 0;
const chain = { compressor, dryGain, compGain, active: false };
audioChains.set(videoEl, chain);
currentAudioVideo = videoEl;
return chain;
} catch (e) {
reportLog(`Audio Processing setup failed: ${e.message}`, 'warn');
return null;
}
}
function rampGain(node, value, t) {
const current = node.gain.value;
node.gain.cancelScheduledValues(t);
node.gain.setValueAtTime(current, t);
node.gain.linearRampToValueAtTime(value, t + 0.04);
}
function applyAudioBypass(videoEl) {
const chain = audioChains.get(videoEl);
if (!chain || !chain.active) return;
const t = chain.dryGain.context.currentTime;
rampGain(chain.dryGain, 1, t);
rampGain(chain.compGain, 0, t);
chain.active = false;
}
function bypassCurrentAudioProcessing() {
if (currentAudioVideo) applyAudioBypass(currentAudioVideo);
}
function applyAudioSettings(videoEl, settings) {
const mergedSettings = mergeAudioSettings(settings);
if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) {
applyAudioBypass(videoEl);
return;
}
const chain = setupAudioChain(videoEl);
if (!chain) return;
const cSettings = mergedSettings.compressor;
const params = cSettings.preset === 'custom'
? cSettings.customParams
: AUDIO_PRESETS[cSettings.preset] || AUDIO_PRESETS.recommended;
chain.compressor.threshold.value = params.threshold ?? -24;
chain.compressor.knee.value = params.knee ?? 15;
chain.compressor.ratio.value = params.ratio ?? 8;
chain.compressor.attack.value = params.attack ?? 0.010;
chain.compressor.release.value = params.release ?? 0.300;
if (!chain.active) {
const t = chain.dryGain.context.currentTime;
rampGain(chain.dryGain, 0, t);
rampGain(chain.compGain, 1, t);
chain.active = true;
}
}
// --- Episode Auto-Sync: Detection ---
@@ -71,6 +284,43 @@
: null;
}
// Extract a canonical episode identifier from a title string.
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
// Returns null if no episode pattern found.
function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null;
// 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);
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
return null;
}
// Returns true if two titles likely refer to the same episode.
// Strict: both must have IDs and match, OR neither has IDs and exact match.
function sameEpisode(titleA, titleB) {
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
if (!titleA || !titleB) return false; // One unknown, one known → different
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
if (idA || idB) return false; // One has ID, other doesn't → different
return titleA === titleB; // Neither has ID → exact string match
}
// Returns true only when we are CERTAIN the episodes differ.
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
// Films, music, unparseable titles always pass through.
function isDifferentEpisode(titleA, titleB) {
if (!titleA || !titleB) return false; // Unknown → allow
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
if (!idA || !idB) return false; // At least one unparseable → allow
return idA !== idB; // Both parseable → only block if different
}
function checkEpisodeTransition() {
const currentTitle = getMediaTitle();
const video = findVideo();
@@ -78,7 +328,7 @@
// Only trigger if: we had a previous title, the title changed,
// a video exists, and we're near the start of new content.
if (lastKnownMediaTitle && currentTitle
&& currentTitle !== lastKnownMediaTitle
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
&& video
&& video.currentTime < 5
&& video.readyState >= 1) {
@@ -111,11 +361,11 @@
const video = findVideo();
const currentTitle = getMediaTitle();
if (video && currentTitle && currentTitle === expectedTitle
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& video.currentTime < 5 && video.readyState >= 1) {
// Match! Pause at start and report ready.
if (!video.paused) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
}
stopLobbyPoll();
@@ -131,7 +381,7 @@
function startLobbyPoll(expectedTitle) {
stopLobbyPoll();
pendingLobbyTitle = expectedTitle;
_pendingLobbyTitle = expectedTitle;
// NOTE: Do NOT pause here. Three callers reach this function:
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
@@ -151,7 +401,7 @@
function stopLobbyPoll() {
pendingLobbyTitle = null;
_pendingLobbyTitle = null;
if (lobbyPollTimer) {
clearInterval(lobbyPollTimer);
lobbyPollTimer = null;
@@ -169,24 +419,24 @@
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
return;
}
data.targetTime = target;
data = { ...data, targetTime: target };
}
try {
const host = window.location.hostname.toLowerCase();
const isYouTube = host.includes('youtube.com');
const isTwitch = host.includes('twitch.tv');
const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com');
const isTwitch = host === 'twitch.tv' || host.endsWith('.twitch.tv');
if (isYouTube) {
const ytButton = document.querySelector('.ytp-play-button');
if (ytButton) {
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
ytButton.click();
}
if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
return;
@@ -198,11 +448,11 @@
if (twitchButton) {
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
twitchButton.click();
}
if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
return;
@@ -211,19 +461,19 @@
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
expectEvent('playing');
_setSuppress('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
expectedEvents.delete('playing');
_clearSuppress('playing');
});
} else if (action === EVENTS.PAUSE) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
} else if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
} catch (e) {
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
}
}
@@ -257,27 +507,62 @@
// 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 : undefined });
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;
// Guard: Don't execute sync commands if peers are on different episodes.
// Only active when autoSyncNextEpisode setting is enabled (default: on).
// Only blocks when BOTH sides have parseable S01E01-style IDs that differ.
// Films and unparseable titles always pass through.
const syncActions = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
if (_autoSyncEnabled && syncActions.includes(action)) {
const senderTitle = payload?.mediaTitle;
const myTitle = getMediaTitle();
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 }).catch(() => {});
}
return;
}
}
if (action === EVENTS.PLAY) {
tryMediaAction(EVENTS.PLAY);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.PAUSE) {
tryMediaAction(EVENTS.PAUSE);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.SEEK) {
tryMediaAction(EVENTS.SEEK, payload);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.FORCE_SYNC_PREPARE) {
if (!payload || payload.targetTime === undefined) return;
@@ -287,21 +572,27 @@
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
return;
}
expectEvent('paused');
expectEvent('seek');
_setSuppress('paused');
_setSuppress('seek');
video.pause();
video.currentTime = payload.targetTime;
try {
video.currentTime = payload.targetTime;
} catch (e) {
reportLog(`Force Sync Seek Error: ${e.message}`, 'error');
}
pollSeekReady(payload.targetTime).then((ready) => {
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
if (ready) {
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
scheduleProactiveHeartbeat();
} else {
reportLog('Force Sync: Seek ready timeout, proceeding anyway', 'warn');
}
});
}).catch(() => {});
}
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
stopLobbyPoll(); // Clear any pending lobby on force sync
stopLobbyPoll();
tryMediaAction(EVENTS.PLAY);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
}
@@ -332,7 +623,7 @@
if (message.type === 'PAUSE_FOR_LOBBY') {
const video = findVideo();
if (video && !video.paused) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
}
// Start lobby poll now that we know the feature is enabled
@@ -345,6 +636,59 @@
if (message.type === 'GET_VIDEO_STATE') {
const video = findVideo();
const platform = (() => {
const h = window.location.hostname.toLowerCase();
if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube';
if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch';
if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix';
if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video';
if (h === 'disneyplus.com' || h.endsWith('.disneyplus.com')) return 'Disney+';
if (h === 'hulu.com' || h.endsWith('.hulu.com')) return 'Hulu';
if (h === 'hbomax.com' || h.endsWith('.hbomax.com') || h === 'max.com' || h.endsWith('.max.com')) return 'Max/HBO';
if (h === 'vimeo.com' || h.endsWith('.vimeo.com')) return 'Vimeo';
if (h === 'dailymotion.com' || h.endsWith('.dailymotion.com')) return 'Dailymotion';
return 'Generic';
})();
const networkStates = ['NETWORK_EMPTY', 'NETWORK_IDLE', 'NETWORK_LOADING', 'NETWORK_NO_SOURCE'];
const readyStates = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA'];
const videoCount = document.querySelectorAll('video').length;
const inShadowDom = (() => {
let el = video;
while (el) {
if (el.toString() === '[object ShadowRoot]') return true;
el = el.parentNode;
}
// Also check if any potential host has shadow root (even if no video found)
if (!video) {
const hosts = document.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i]');
for (const host of hosts) {
if (host.shadowRoot) return true;
}
}
return false;
})();
// Build multi-video summary for debug reports
const allVideos = [];
const allVideoEls = document.querySelectorAll('video');
for (let i = 0; i < allVideoEls.length; i++) {
const v = allVideoEls[i];
allVideos.push({
index: i,
width: v.videoWidth || v.offsetWidth || 0,
height: v.videoHeight || v.offsetHeight || 0,
muted: v.muted,
paused: v.paused,
duration: (v.duration && isFinite(v.duration)) ? Math.round(v.duration) : 0,
readyState: v.readyState,
src: (v.currentSrc || v.src || '').substring(0, 80),
selected: v === video
});
}
if (video) {
const dataAttributes = {};
if (video.attributes) {
@@ -362,30 +706,78 @@
artwork: Array.from(navigator.mediaSession.metadata.artwork || []).map(a => a.src)
} : null;
const errorInfo = video.error ? {
code: video.error.code,
message: video.error.message
} : null;
sendResponse({
found: true,
paused: video.paused,
currentTime: video.currentTime,
duration: video.duration || 0,
readyState: video.readyState,
readyStateLabel: readyStates[video.readyState] || 'UNKNOWN',
networkState: video.networkState,
networkStateLabel: networkStates[video.networkState] || 'UNKNOWN',
muted: video.muted,
volume: video.volume,
playbackRate: video.playbackRate,
videoWidth: video.videoWidth,
videoHeight: video.videoHeight,
seeking: video.seeking,
ended: video.ended,
error: errorInfo,
buffered: video.buffered && video.buffered.length > 0
? Array.from({ length: video.buffered.length }, (_, i) =>
`${video.buffered.start(i).toFixed(1)}-${video.buffered.end(i).toFixed(1)}s`).join(', ')
: 'none',
loop: video.loop,
url: window.location.href,
pageTitle: document.title,
id: video.id || 'none',
className: video.className || 'none',
src: video.src || 'none',
currentSrc: video.currentSrc || 'none',
dataAttributes,
metadata
metadata,
videoCount,
inShadowDom,
platform,
allVideos
});
} else {
sendResponse({ error: 'No video found' });
sendResponse({
found: false,
videoCount,
inShadowDom,
platform,
allVideos,
url: window.location.href,
pageTitle: document.title,
metadata: (navigator.mediaSession && navigator.mediaSession.metadata) ? {
title: navigator.mediaSession.metadata.title,
artist: navigator.mediaSession.metadata.artist,
album: navigator.mediaSession.metadata.album
} : null
});
}
}
});
// Detect native events
function reportEvent(action) {
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
clearTimeout(seekDebounceTimer);
seekDebounceTimer = null;
const v = findVideo();
if (v && Number.isFinite(v.currentTime)) {
lastReportedSeekTime = v.currentTime;
reportLog(`[Seek] Debounce flushed immediately due to ${action.toUpperCase()}`, 'info');
reportEvent(EVENTS.SEEK);
}
}
const video = findVideo();
if (!video) return;
@@ -396,10 +788,14 @@
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
if (eventState && expectedEvents.has(eventState)) {
expectedEvents.delete(eventState); // Consume the match
return; // Ignore event caused by our programmatic action
if (_suppressTimers[eventState]) {
_clearSuppress(eventState);
return;
}
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
// Play/Pause pass through — user may want to immediately pause after tabbing back.
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
@@ -410,12 +806,47 @@
mediaTitle: mediaTitle,
timestamp: Date.now()
}
});
}).catch(() => {});
// Trigger proactive heartbeat to push stabilized state
scheduleProactiveHeartbeat();
}
// --- Tab Visibility Handling ---
// Browsers (especially Firefox) aggressively throttle background tabs.
// When the user returns to a video tab, the video element may have lost
// time-sync and fires spurious seek events as it recovers (jumping back).
// We suppress only SEEK for a short grace period after tab re-focus.
// Play/Pause are NOT suppressed — the user may legitimately want to
// pause immediately after switching back.
let pageVisible = !document.hidden;
let visibilityGraceUntil = 0;
const VISIBILITY_GRACE_MS = 300;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
pageVisible = false;
} else if (!pageVisible) {
pageVisible = true;
visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS;
reportLog(`Tab re-focused — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s to prevent ghost relay`, 'warn');
}
});
// Reset on page hide/show (bfcache, tab discard)
window.addEventListener('pagehide', () => {
pageVisible = false;
closeAudioContext();
});
window.addEventListener('pageshow', (event) => {
// event.persisted is true ONLY when restored from bfcache, not on initial load
if (event.persisted && !pageVisible) {
pageVisible = true;
visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS;
reportLog(`Page restored from cache — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s`, 'warn');
}
});
const handlePlay = () => reportEvent(EVENTS.PLAY);
const handlePause = () => reportEvent(EVENTS.PAUSE);
@@ -427,25 +858,27 @@
const current = video.currentTime;
if (!Number.isFinite(current)) return;
// Step 1: Check expectedEvents (programmatic seek suppression)
if (expectedEvents.has('seek')) {
expectedEvents.delete('seek');
lastReportedSeekTime = current; // Update baseline so next user seek is relative to here
// No log — this is routine programmatic behavior (Force Sync, lobby, peer command)
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
if (_suppressTimers['seek']) {
_clearSuppress('seek');
lastReportedSeekTime = current;
return;
}
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
if (Date.now() < visibilityGraceUntil) return;
const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null;
const deltaStr = delta !== null ? `Δ${delta.toFixed(2)}s` : 'Δ?';
// Step 2: Delta check — skip micro-seeks (buffering, chapter markers, etc.)
// Step 3: Delta check — skip micro-seeks (buffering, chapter markers, etc.)
if (lastReportedSeekTime !== null && delta < MIN_SEEK_DELTA) {
reportLog(`[Seek] Filtered (${deltaStr} < ${MIN_SEEK_DELTA}s threshold) @ ${current.toFixed(2)}s — not relayed`, 'warn');
return;
}
// Step 3: Debounce rapid consecutive seeks (e.g. scrubbing)
// — wait 800ms for the user to settle before relaying
// Step 4: Debounce rapid consecutive seeks (e.g. scrubbing)
// — wait 300ms for the user to settle before relaying
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
seekDebounceTimer = setTimeout(() => {
seekDebounceTimer = null;
@@ -457,11 +890,11 @@
lastReportedSeekTime = settled;
reportLog(`[Seek] Relayed @ ${settled.toFixed(2)}s (${finalDeltaStr})`, 'info');
reportEvent(EVENTS.SEEK);
}, 800);
}, 300);
};
let lastVideoSrc = null;
let lastVideoSrc = undefined;
// Episode detection handler for loadeddata event
const handleLoadedData = () => {
@@ -471,22 +904,30 @@
function setupListeners() {
const video = findVideo();
if (video) {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('seeked', handleSeeked);
video.removeEventListener('loadeddata', handleLoadedData);
if (currentAudioVideo && currentAudioVideo !== video) {
bypassCurrentAudioProcessing();
}
const existing = video._koalaHandlers;
if (existing) {
video.removeEventListener('play', existing.play);
video.removeEventListener('pause', existing.pause);
video.removeEventListener('seeked', existing.seeked);
video.removeEventListener('loadeddata', existing.loadeddata);
}
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData };
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('loadeddata', handleLoadedData);
video.dataset.koalaAttached = 'true';
lastVideoSrc = video.currentSrc || video.src;
lastVideoSrc = video.currentSrc || video.src || null;
// Initialize episode tracking title on first attach
if (!lastKnownMediaTitle) {
lastKnownMediaTitle = getMediaTitle();
}
if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings);
}
}
@@ -497,13 +938,20 @@
function checkVideo() {
lastMutate = Date.now();
const video = findVideo();
if (!video && lastVideoSrc !== undefined) {
reportLog('Video element removed from page', 'warn');
lastVideoSrc = undefined;
closeAudioContext();
return;
}
if (!video) return;
const currentSrc = video.currentSrc || video.src;
const currentSrc = video.currentSrc || video.src || null;
if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) {
// If src changed, also check for episode transition
if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) {
if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) {
if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) {
checkEpisodeTransition();
}
setupListeners();
@@ -519,7 +967,7 @@
observerTimeout = setTimeout(checkVideo, 1000 - (now - lastMutate));
}
});
observer.observe(document.body, { childList: true, subtree: true });
observer.observe(document.documentElement, { childList: true, subtree: true });
// --- SHARED_HEARTBEAT_INJECT_START ---
const HEARTBEAT_INTERVAL_VAL = 15000;
@@ -575,10 +1023,32 @@
// Initial Setup
setupListeners();
// Maintain a persistent keep-alive port connection to prevent background SW suspension
let keepAlivePort = null;
function connectKeepAlivePort() {
try {
if (chrome.runtime.id) {
keepAlivePort = chrome.runtime.connect({ name: 'keepAlive' });
keepAlivePort.onDisconnect.addListener(() => {
keepAlivePort = null;
setTimeout(connectKeepAlivePort, 1000);
});
}
} catch (_e) {
// Extension context invalidated or disabled
}
}
connectKeepAlivePort();
schedulePeriodicHeartbeat();
// Immediate heartbeat on injection — populate peer data without waiting 15s
setTimeout(() => sendHeartbeat(), 300);
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
if (chrome.runtime.lastError) return;
if (res && res.lobbyActive && res.expectedTitle) {
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
startLobbyPoll(res.expectedTitle);
+136
View File
@@ -0,0 +1,136 @@
// extension/i18n.js
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.
* @param {string} langCode - Target language (e.g. 'de')
*/
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
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;
}
// Load Target Locale
const targetResponse = await fetch(chrome.runtime.getURL(`locales/${resolvedLang}.json`));
const targetDict = await targetResponse.json();
// Airtight Fallback Merge: target overrides en, missing elements fallback to en
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 {
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;
}
}
}
/**
* Returns the translated string for a given key. Supports optional value interpolation.
* @param {string} key - Dictionary key
* @param {object} [placeholders] - Key-value map for replacements (e.g., { name: 'Alice' })
* @returns {string} Translated string or the key itself
*/
export function getMessage(key, placeholders = null) {
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);
}
}
return msg;
}
/**
* Performs dynamic DOM replacements for elements carrying data-i18n attributes.
*/
export function translateDOM() {
// 1. Text Content
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const translated = getMessage(key);
// Special case: Preserve logo image elements inside headers (like h1 logo)
const img = el.querySelector('img');
if (img) {
el.innerHTML = '';
el.appendChild(img);
el.appendChild(document.createTextNode(' ' + translated));
} else {
el.textContent = translated;
}
});
// 2. Tooltips (titles)
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.setAttribute('title', getMessage(key));
});
// 3. Placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
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: 26 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

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.

After

Width:  |  Height:  |  Size: 10 KiB

+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "de",
"HTML_CLASS": "lang-de",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Raum",
"TAB_ROOM_TOOLTIP": "Raum-Einstellungen und Verbindung",
"TAB_SYNC": "Sync",
"TAB_SYNC_TOOLTIP": "Video-Synchronisations-Steuerung und Aktionen",
"TAB_SETTINGS": "Optionen",
"TAB_SETTINGS_TOOLTIP": "Erweiterungseinstellungen",
"TAB_STATUS": "Status",
"TAB_STATUS_TOOLTIP": "Erweiterte Diagnose & Protokolle",
"BTN_CREATE_ROOM": "+ Neuer Raum",
"MANUAL_CONNECT_HEADER": "Manuell verbinden / Erweitert",
"LABEL_SERVER": "Server",
"BTN_SERVER_OFFICIAL": "Offiziell",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Verwende den offiziellen, zuverlässigen Server",
"BTN_SERVER_CUSTOM": "Eigener",
"BTN_SERVER_CUSTOM_TOOLTIP": "Verbinde dich mit deinem eigenen, selbstgehosteten Server",
"PLACEHOLDER_SERVER_URL": "wss://dein-server:3000",
"LABEL_ROOM_ID": "Raum-ID",
"LABEL_ROOM_ID_TOOLTIP": "Der eindeutige Identifikator für deinen Sync-Raum",
"PLACEHOLDER_ROOM_ID": "Raum-ID eingeben",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Die eindeutige ID des Raums, dem du beitreten möchtest",
"LABEL_PASSWORD": "Passwort (Optional)",
"LABEL_PASSWORD_TOOLTIP": "Optionales Passwort, um den Raumzugang zu beschränken",
"PLACEHOLDER_PASSWORD": "Raum-Passwort (optional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Passwort für den Raum (leer lassen, wenn keines vorhanden)",
"BTN_JOIN_ROOM": "Raum beitreten / erstellen",
"BTN_JOIN_ROOM_TOOLTIP": "Mit dem Raum verbinden",
"LABEL_PUBLIC_ROOMS": "Öffentliche Räume",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Liste der öffentlich verfügbaren Räume auf diesem Server",
"BTN_REFRESH": "AKTUALISIEREN",
"BTN_REFRESH_TOOLTIP": "Die Liste der öffentlichen Räume aktualisieren",
"PUBLIC_ROOMS_REFRESHING": "Aktualisiere...",
"BTN_REFRESH_COOLDOWN": "WARTE {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Die Raumliste kühlt ab. Versuche es in {seconds}s erneut.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aktualisiere öffentliche Räume. Nächste Aktualisierung in {seconds}s verfügbar.",
"LABEL_ACTIVE_ROOM": "Aktiver Raum",
"LABEL_ACTIVE_ROOM_TOOLTIP": "Der Raum, mit dem du gerade verbunden bist",
"ACTIVE_ROOM_NONE": "KEINER",
"ACTIVE_SERVER_OFFICIAL": "Offizieller Server",
"LABEL_INVITE_LINK": "Einladungslink",
"LABEL_INVITE_LINK_TOOLTIP": "Teile diesen Link mit Freunden, damit sie beitreten können",
"LABEL_PEERS_IN_ROOM": "Teilnehmer im Raum",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere Benutzer, die derzeit mit diesem Raum verbunden sind",
"NO_PEERS_CONNECTED": "Keine Teilnehmer verbunden",
"BTN_LEAVE_ROOM": "Raum verlassen",
"LABEL_SELECT_VIDEO": "Video auswählen",
"LABEL_SELECT_VIDEO_TOOLTIP": "Wähle den Browser-Tab aus, der das zu synchronisierende Video enthält",
"OPTION_SELECT_TAB": "-- Wähle einen Tab --",
"LABEL_REMOTE_CONTROL": "Fernsteuerung",
"BTN_COPY_INVITE": "📋 Einladungslink",
"BTN_COPY_INVITE_TOOLTIP": "Einladungslink kopieren",
"BTN_PLAY": "▶ Abspielen",
"BTN_PLAY_TOOLTIP": "Sende einen Play-Befehl an alle",
"BTN_PAUSE": "⏸ Pause",
"BTN_PAUSE_TOOLTIP": "Sende einen Pause-Befehl an alle",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Erzwinge die Synchronisation aller Teilnehmer",
"OPTION_JUMP_TO_OTHERS": "Zu anderen springen",
"OPTION_JUMP_TO_ME": "Zu mir springen",
"OPTION_JUMP_MODE_TOOLTIP": "Synchronisationsziel auswählen",
"LABEL_LAST_ACTIVITY": "Letzter Aktivitätsstatus",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Zeigt den neuesten Play-, Pause- oder Seek-Befehl",
"NO_RECENT_COMMANDS": "Keine aktuellen Befehle",
"LOBBY_HEADER": "EPISODEN-LOBBY",
"LOBBY_WAITING_FOR": "🎬 Warte auf: \"{title}\"",
"LOBBY_WAITING_PEERS": "Warte auf Teilnehmer...",
"BTN_SKIP_PLAY": "Überspringen & Abspielen",
"BTN_SKIP_PLAY_TOOLTIP": "Lobby abbrechen und trotzdem abspielen",
"LOBBY_CONNECT_FIRST": "Zuerst Raum beitreten",
"LOBBY_CONNECT_FIRST_DESC": "Du musst über einen Einladungslink beitreten oder einen neuen Raum erstellen, um Videos zu synchronisieren.",
"BTN_CREATE_ROOM_ALT": "Neuen Raum erstellen",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Einen neuen zufälligen Raum erstellen und beitreten",
"LABEL_USERNAME": "Dein Benutzername",
"LABEL_USERNAME_TOOLTIP": "Der Benutzername hilft anderen, dich zu identifizieren.",
"PLACEHOLDER_USERNAME": "Anonymer Koala",
"LABEL_HIDE_CLUTTER": "Aufgeräumte Tab-Liste",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtert Nicht-Video-Tabs und irrelevante Domains heraus, um die Liste sauber zu halten",
"LABEL_AUTO_SYNC_NEXT": "Nächste Episode auto-syncen",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausiert automatisch und wartet auf alle Teilnehmer bei Episodenwechsel, startet dann synchron.",
"LABEL_AUTO_COPY_INVITE": "Einladungslink auto-kopieren",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Kopiert den Einladungslink automatisch beim Erstellen eines Raums.",
"LABEL_NOTIFICATIONS": "Browser-Benachrichtigungen",
"LABEL_NOTIFICATIONS_TOOLTIP": "Zeigt Systembenachrichtigungen, wenn jemand beitritt/verlässt oder abspielt/pausiert.",
"LABEL_LANGUAGE": "Sprache",
"LABEL_LANGUAGE_TOOLTIP": "Wähle deine bevorzugte Sprache für die Erweiterung aus",
"LABEL_TROUBLESHOOTING": "Fehlerbehebung",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Werkzeuge zur Behebung von Verbindungsproblemen",
"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",
"BTN_RETRY": "WIEDERHOLEN",
"BTN_RETRY_TOOLTIP": "Versuchen, die Verbindung zum Server wiederherzustellen",
"BTN_COPY_LOGS": "Logs kopieren",
"BTN_COPY_LOGS_TOOLTIP": "Logs zum Teilen in die Zwischenablage kopieren",
"LABEL_VIDEO_DEBUG": "Video-Debug-Info",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Technische Details zum aktuell ausgewählten Video-Element",
"VIDEO_DEBUG_EMPTY": "Kein Tab ausgewählt oder kein Video erkannt.",
"LABEL_HISTORY": "Aktionsverlauf",
"LABEL_HISTORY_TOOLTIP": "Chronologisches Protokoll aller Sync-Befehle im Raum",
"HISTORY_EMPTY": "Noch keine Aktivität",
"LABEL_LOGS": "Logs (Letzte 50)",
"LABEL_LOGS_TOOLTIP": "Technische Verbindungsprotokolle zur Fehlersuche",
"BTN_CLEAR": "LEEREN",
"BTN_CLEAR_TOOLTIP": "Protokollausgabe leeren",
"LABEL_GITHUB": "GitHub-Repository",
"BTN_ONBOARDING_SKIP": "Überspringen",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Einführung überspringen",
"BTN_ONBOARDING_NEXT": "Weiter",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Zum nächsten Schritt gehen",
"ONBOARDING_1_TITLE": "Willkommen bei KoalaSync!",
"ONBOARDING_1_TEXT": "Schau Videos in perfekter Synchronität zusammen — egal wo ihr seid. Machen wir eine kurze Tour!",
"ONBOARDING_2_TITLE": "1. Raum erstellen",
"ONBOARDING_2_TEXT": "Hier geht's los. Erstelle einen Raum und teile den Einladungslink mit deinen Freunden.",
"ONBOARDING_3_TITLE": "2. Video auswählen",
"ONBOARDING_3_TEXT": "Wähle hier das Video aus, das du synchronisieren willst. Abspielen, Pause, Spulen — alle bleiben synchron.",
"ONBOARDING_4_TITLE": "3. Personalisieren",
"ONBOARDING_4_TEXT": "Wähle einen lustigen Benutzernamen, damit deine Freunde wissen, wer du bist.",
"ONBOARDING_5_TITLE": "Alles startklar!",
"ONBOARDING_5_TEXT": "Zeit, das Popcorn zu holen. Viel Spaß beim gemeinsamen Schauen!",
"ERR_CONN_TIMEOUT": "Verbindung abgelaufen. Bitte versuche es erneut.",
"ERR_INVALID_SERVER_URL": "Ungültiges Server-URL-Format.",
"ERR_IDENTITY_NOT_LOADED": "Identität noch nicht geladen. Warte kurz und versuche es erneut.",
"ERR_NO_PEERS_TIME": "Keine anderen Teilnehmer mit bekannter Zeit. Wechsle zu 'Zu mir springen'.",
"ERR_NO_VIDEO_TAB": "Verbindung zum Video-Tab fehlgeschlagen.",
"ERR_SELECT_VIDEO": "Bitte wähle zuerst ein Video aus!",
"TOAST_INVITE_COPIED": "Einladungslink kopiert!",
"TOAST_COPY_FAILED": "Kopieren in Zwischenablage fehlgeschlagen",
"TOAST_LOBBY_SKIPPED": "Episoden-Lobby übersprungen.",
"TOAST_LOBBY_SKIP_FAILED": "Überspringen der Lobby fehlgeschlagen.",
"TOAST_LOGS_COPIED": "Kopiert!",
"TOAST_PEER_JOINED": "{name} ist dem Raum beigetreten",
"TOAST_PEER_LEFT": "{name} hat den Raum verlassen",
"TOAST_PEER_ACTION": "{name} hat {action}",
"STATUS_CONNECTED": "Verbunden",
"STATUS_RECONNECTING": "Verbinde erneut...",
"STATUS_CONNECTING": "Verbinde...",
"STATUS_FAILED": "Fehlgeschlagen",
"STATUS_DISCONNECTED": "Getrennt",
"BTN_STATE_JOINING": "🚀 Trete bei...",
"BTN_STATE_RECONNECTING": "🔄 Verbinde erneut...",
"BTN_STATE_PLAYING": "▶ Spiele ab...",
"BTN_STATE_PAUSING": "⏸ Pausiere...",
"BTN_STATE_SYNCING_GROUP": "Synce zur Gruppe ({time})...",
"BTN_STATE_SYNCING": "Synchronisiere...",
"BTN_STATE_SYNCED": "✅ Synchronisiert!",
"NOTIF_PLAY": "die Wiedergabe gestartet",
"NOTIF_PAUSE": "die Wiedergabe pausiert",
"NOTIF_SEEK": "im Video gespult",
"NOTIF_FORCE_PREPARE": "eine erzwungene Synchronisation gestartet",
"NOTIF_FORCE_EXECUTE": "alle Teilnehmer synchronisiert",
"DEBUG_NO_TAB": "Kein Ziel-Tab ausgewählt.",
"DEBUG_COMM_FAIL": "Kommunikation mit dem Tab-Video fehlgeschlagen.",
"EMPTY_PEERS_TITLE": "Noch keine Teilnehmer",
"EMPTY_PEERS_HINT": "Teile deinen Einladungslink, um loszulegen",
"EMPTY_HISTORY_TITLE": "Noch keine Aktivität",
"EMPTY_HISTORY_HINT": "Abspielen, Pausieren oder Spulen, um Verlauf zu sehen",
"EMPTY_LOGS_TITLE": "Keine Logs",
"EMPTY_LOGS_HINT": "Verbindungsereignisse werden hier angezeigt",
"EMPTY_ROOMS_TITLE": "Keine aktiven Räume",
"EMPTY_ROOMS_HINT": "Raum erstellen oder aktualisieren, um öffentliche zu finden",
"LABEL_YOU": "Du",
"ONBOARDING_DONE": "Fertig!",
"LABEL_LOBBY_PEER_READY": "Bereit",
"LABEL_LOBBY_PEER_LOADING": "Lädt...",
"LABEL_PASSWORD_PROTECTED": "Passwortgeschützt",
"LABEL_PEERS_COUNT": "{count} Teilnehmer",
"LABEL_CUSTOM_SERVER": "Eigener Server",
"BTN_STATE_CREATING": "🚀 Erstelle Raum...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Episoden-Synchronisation fehlgeschlagen",
"NOTIF_LOBBY_CANCEL_MSG": "Auto-Sync abgebrochen: {reason}. Du musst eventuell manuell synchronisieren.",
"LOBBY_CANCEL_TIMEOUT": "Zeitüberschreitung",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Zeitüberschreitung (wiederhergestellt)",
"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",
"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"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "en",
"HTML_CLASS": "lang-en",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Room",
"TAB_ROOM_TOOLTIP": "Room settings and connection",
"TAB_SYNC": "Sync",
"TAB_SYNC_TOOLTIP": "Video sync controls and remote actions",
"TAB_SETTINGS": "Settings",
"TAB_SETTINGS_TOOLTIP": "Extension preferences",
"TAB_STATUS": "Status",
"TAB_STATUS_TOOLTIP": "Advanced Diagnostics & Logs",
"BTN_CREATE_ROOM": "+ Create New Room",
"MANUAL_CONNECT_HEADER": "Manual Connect / Advanced",
"LABEL_SERVER": "Server",
"BTN_SERVER_OFFICIAL": "Official",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Use the official reliable server",
"BTN_SERVER_CUSTOM": "Custom",
"BTN_SERVER_CUSTOM_TOOLTIP": "Connect to your own self-hosted server",
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
"LABEL_ROOM_ID": "Room ID",
"LABEL_ROOM_ID_TOOLTIP": "The unique identifier for your sync room",
"PLACEHOLDER_ROOM_ID": "Enter Room ID",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "The unique ID of the room you want to join",
"LABEL_PASSWORD": "Password (Optional)",
"LABEL_PASSWORD_TOOLTIP": "Optional password to restrict room access",
"PLACEHOLDER_PASSWORD": "Room Password (optional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password for the room (leave empty if none)",
"BTN_JOIN_ROOM": "Join / Create Room",
"BTN_JOIN_ROOM_TOOLTIP": "Connect to the room",
"LABEL_PUBLIC_ROOMS": "Public Rooms",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "List of publicly available rooms on this server",
"BTN_REFRESH": "REFRESH",
"BTN_REFRESH_TOOLTIP": "Refresh the list of public rooms",
"PUBLIC_ROOMS_REFRESHING": "Refreshing...",
"BTN_REFRESH_COOLDOWN": "WAIT {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Room list refresh is cooling down. Try again in {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Refreshing public rooms. Next refresh available in {seconds}s.",
"LABEL_ACTIVE_ROOM": "Active Room",
"LABEL_ACTIVE_ROOM_TOOLTIP": "The room you are currently connected to",
"ACTIVE_ROOM_NONE": "NONE",
"ACTIVE_SERVER_OFFICIAL": "Official Server",
"LABEL_INVITE_LINK": "Invite Link",
"LABEL_INVITE_LINK_TOOLTIP": "Share this link with friends so they can join",
"LABEL_PEERS_IN_ROOM": "Peers in Room",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Other users currently connected to this room",
"NO_PEERS_CONNECTED": "No peers connected",
"BTN_LEAVE_ROOM": "Leave Room",
"LABEL_SELECT_VIDEO": "Select Video",
"LABEL_SELECT_VIDEO_TOOLTIP": "Choose the browser tab containing the video to sync",
"OPTION_SELECT_TAB": "-- Select a Tab --",
"LABEL_REMOTE_CONTROL": "Remote Control",
"BTN_COPY_INVITE": "📋 Invite Link",
"BTN_COPY_INVITE_TOOLTIP": "Copy Invite Link",
"BTN_PLAY": "▶ Play",
"BTN_PLAY_TOOLTIP": "Send a Play command to everyone",
"BTN_PAUSE": "⏸ Pause",
"BTN_PAUSE_TOOLTIP": "Send a Pause command to everyone",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Force all users to sync up",
"OPTION_JUMP_TO_OTHERS": "Jump to Others",
"OPTION_JUMP_TO_ME": "Jump to Me",
"OPTION_JUMP_MODE_TOOLTIP": "Choose sync target",
"LABEL_LAST_ACTIVITY": "Last Activity Status",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Shows the most recent play, pause, or seek command",
"NO_RECENT_COMMANDS": "No recent commands",
"LOBBY_HEADER": "EPISODE LOBBY",
"LOBBY_WAITING_FOR": "🎬 Waiting for: \"{title}\"",
"LOBBY_WAITING_PEERS": "Waiting for peers...",
"BTN_SKIP_PLAY": "Skip & Play anyway",
"BTN_SKIP_PLAY_TOOLTIP": "Cancel lobby and play anyway",
"LOBBY_CONNECT_FIRST": "Connect to a room first",
"LOBBY_CONNECT_FIRST_DESC": "You need to join a room via an invite link or create a new one to sync videos.",
"BTN_CREATE_ROOM_ALT": "Create New Room",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Create a new random room and join it",
"LABEL_USERNAME": "Your Username",
"LABEL_USERNAME_TOOLTIP": "Username helps others identify you.",
"PLACEHOLDER_USERNAME": "Anonymous Koala",
"LABEL_HIDE_CLUTTER": "Hide Clutter Tabs",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filters out non-video tabs and unrelated domains to keep the list clean",
"LABEL_AUTO_SYNC_NEXT": "Auto-Sync Next Episode",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pauses automatically and waits for all peers when an episode changes, then sync-starts together.",
"LABEL_AUTO_COPY_INVITE": "Auto-Copy Invite Link",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Automatically copies the invite link to your clipboard when creating a new room.",
"LABEL_NOTIFICATIONS": "Browser Notifications",
"LABEL_NOTIFICATIONS_TOOLTIP": "Shows native system notifications when someone joins/leaves or plays/pauses.",
"LABEL_LANGUAGE": "App Language",
"LABEL_LANGUAGE_TOOLTIP": "Choose your preferred extension language",
"LABEL_TROUBLESHOOTING": "Troubleshooting",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Tools for fixing connection issues",
"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",
"BTN_RETRY": "RETRY",
"BTN_RETRY_TOOLTIP": "Attempt to reconnect to the server",
"BTN_COPY_LOGS": "Copy Logs",
"BTN_COPY_LOGS_TOOLTIP": "Copy logs to clipboard for sharing",
"LABEL_VIDEO_DEBUG": "Video Debug Info",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Technical details about the currently selected video element",
"VIDEO_DEBUG_EMPTY": "No tab selected or video detected.",
"LABEL_HISTORY": "Full Action History",
"LABEL_HISTORY_TOOLTIP": "Chronological log of all sync commands in the room",
"HISTORY_EMPTY": "No activity yet",
"LABEL_LOGS": "Logs (Last 50)",
"LABEL_LOGS_TOOLTIP": "Technical connection logs for debugging",
"BTN_CLEAR": "CLEAR",
"BTN_CLEAR_TOOLTIP": "Clear log output",
"LABEL_GITHUB": "GitHub Repository",
"BTN_ONBOARDING_SKIP": "Skip",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Skip the tutorial",
"BTN_ONBOARDING_NEXT": "Next",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Go to next step",
"ONBOARDING_1_TITLE": "Welcome to KoalaSync!",
"ONBOARDING_1_TEXT": "Watch videos together in perfect sync — no matter where you are. Let's take a quick tour!",
"ONBOARDING_2_TITLE": "1. Create a Room",
"ONBOARDING_2_TEXT": "Start here. Create a room and share the invite link with your friends.",
"ONBOARDING_3_TITLE": "2. Select Video",
"ONBOARDING_3_TEXT": "Navigate here to select the video you want to sync. Play, pause, and seek — everyone stays in sync.",
"ONBOARDING_4_TITLE": "3. Personalize",
"ONBOARDING_4_TEXT": "Pick a fun username so your friends know who you are.",
"ONBOARDING_5_TITLE": "You're all set!",
"ONBOARDING_5_TEXT": "Time to grab some popcorn. Enjoy watching together!",
"ERR_CONN_TIMEOUT": "Connection timed out. Please try again.",
"ERR_INVALID_SERVER_URL": "Invalid Server URL format.",
"ERR_IDENTITY_NOT_LOADED": "Identity not yet loaded. Wait a moment and try again.",
"ERR_NO_PEERS_TIME": "No other peers with a known time. Switch to 'Jump to Me'.",
"ERR_NO_VIDEO_TAB": "Could not connect to video tab.",
"ERR_SELECT_VIDEO": "Please select a video first!",
"TOAST_INVITE_COPIED": "Invite link copied!",
"TOAST_COPY_FAILED": "Failed to copy to clipboard",
"TOAST_LOBBY_SKIPPED": "Episode Lobby skipped.",
"TOAST_LOBBY_SKIP_FAILED": "Failed to skip lobby.",
"TOAST_LOGS_COPIED": "Copied!",
"TOAST_PEER_JOINED": "{name} joined the room",
"TOAST_PEER_LEFT": "{name} left the room",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Connected",
"STATUS_RECONNECTING": "Reconnecting...",
"STATUS_CONNECTING": "Connecting...",
"STATUS_FAILED": "Failed",
"STATUS_DISCONNECTED": "Disconnected",
"BTN_STATE_JOINING": "🚀 Joining...",
"BTN_STATE_RECONNECTING": "🔄 Reconnecting...",
"BTN_STATE_PLAYING": "▶ Playing...",
"BTN_STATE_PAUSING": "⏸ Pausing...",
"BTN_STATE_SYNCING_GROUP": "Syncing to group ({time})...",
"BTN_STATE_SYNCING": "Syncing...",
"BTN_STATE_SYNCED": "✅ Synced!",
"NOTIF_PLAY": "started playback",
"NOTIF_PAUSE": "paused playback",
"NOTIF_SEEK": "seeked the video",
"NOTIF_FORCE_PREPARE": "started force sync",
"NOTIF_FORCE_EXECUTE": "synchronized everyone",
"DEBUG_NO_TAB": "No target tab selected.",
"DEBUG_COMM_FAIL": "Could not communicate with tab video.",
"EMPTY_PEERS_TITLE": "No peers yet",
"EMPTY_PEERS_HINT": "Share your invite link to get started",
"EMPTY_HISTORY_TITLE": "No activity yet",
"EMPTY_HISTORY_HINT": "Play, pause, or seek to see history",
"EMPTY_LOGS_TITLE": "No logs",
"EMPTY_LOGS_HINT": "Connection events will appear here",
"EMPTY_ROOMS_TITLE": "No active rooms",
"EMPTY_ROOMS_HINT": "Create a room or refresh to find public ones",
"LABEL_YOU": "You",
"ONBOARDING_DONE": "Done!",
"LABEL_LOBBY_PEER_READY": "Ready",
"LABEL_LOBBY_PEER_LOADING": "Loading...",
"LABEL_PASSWORD_PROTECTED": "Password Protected",
"LABEL_PEERS_COUNT": "{count} peers",
"LABEL_CUSTOM_SERVER": "Custom Server",
"BTN_STATE_CREATING": "🚀 Creating Room...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Episode Sync Failed",
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync cancelled: {reason}. You may need to manually sync.",
"LOBBY_CANCEL_TIMEOUT": "Timeout",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (recovered)",
"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",
"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"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "es",
"HTML_CLASS": "lang-es",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Sala",
"TAB_ROOM_TOOLTIP": "Configuración de sala y conexión",
"TAB_SYNC": "Sincro",
"TAB_SYNC_TOOLTIP": "Controles de sincronización de video y acciones",
"TAB_SETTINGS": "Opciones",
"TAB_SETTINGS_TOOLTIP": "Preferencias de la extensión",
"TAB_STATUS": "Estado",
"TAB_STATUS_TOOLTIP": "Diagnósticos avanzados y registros",
"BTN_CREATE_ROOM": "+ Crear nueva sala",
"MANUAL_CONNECT_HEADER": "Conexión manual / Avanzado",
"LABEL_SERVER": "Servidor",
"BTN_SERVER_OFFICIAL": "Oficial",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar el servidor oficial confiable",
"BTN_SERVER_CUSTOM": "Perso",
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectarse a su propio servidor auto-alojado",
"PLACEHOLDER_SERVER_URL": "wss://tu-servidor:3000",
"LABEL_ROOM_ID": "ID de sala",
"LABEL_ROOM_ID_TOOLTIP": "El identificador único para tu sala de sincronización",
"PLACEHOLDER_ROOM_ID": "Ingresa el ID de la sala",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "El ID único de la sala a la que deseas unirte",
"LABEL_PASSWORD": "Contraseña (Opcional)",
"LABEL_PASSWORD_TOOLTIP": "Contraseña opcional para restringir el acceso a la sala",
"PLACEHOLDER_PASSWORD": "Contraseña de la sala (opcional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Contraseña para la sala (dejar vacío si no hay)",
"BTN_JOIN_ROOM": "Unirse / Crear sala",
"BTN_JOIN_ROOM_TOOLTIP": "Conectarse a la sala",
"LABEL_PUBLIC_ROOMS": "Salas públicas",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponibles en este servidor",
"BTN_REFRESH": "ACTUALIZAR",
"BTN_REFRESH_TOOLTIP": "Actualizar la lista de salas públicas",
"PUBLIC_ROOMS_REFRESHING": "Actualizando...",
"BTN_REFRESH_COOLDOWN": "ESPERA {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "La lista de salas está en espera. Inténtalo de nuevo en {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualizando salas públicas. Próxima actualización en {seconds}s.",
"LABEL_ACTIVE_ROOM": "Sala activa",
"LABEL_ACTIVE_ROOM_TOOLTIP": "La sala a la que estás conectado actualmente",
"ACTIVE_ROOM_NONE": "NINGUNA",
"ACTIVE_SERVER_OFFICIAL": "Servidor oficial",
"LABEL_INVITE_LINK": "Enlace de invitación",
"LABEL_INVITE_LINK_TOOLTIP": "Comparte este enlace con amigos para que se unan",
"LABEL_PEERS_IN_ROOM": "Participantes en la sala",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Otros usuarios conectados actualmente a esta sala",
"NO_PEERS_CONNECTED": "Sin participantes conectados",
"BTN_LEAVE_ROOM": "Salir de la sala",
"LABEL_SELECT_VIDEO": "Seleccionar video",
"LABEL_SELECT_VIDEO_TOOLTIP": "Elige la pestaña del navegador que contiene el video a sincronizar",
"OPTION_SELECT_TAB": "-- Selecciona una pestaña --",
"LABEL_REMOTE_CONTROL": "Control remoto",
"BTN_COPY_INVITE": "📋 Enlace de invitación",
"BTN_COPY_INVITE_TOOLTIP": "Copiar enlace de invitación",
"BTN_PLAY": "▶ Reproducir",
"BTN_PLAY_TOOLTIP": "Enviar un comando de reproducción a todos",
"BTN_PAUSE": "⏸ Pausar",
"BTN_PAUSE_TOOLTIP": "Enviar un comando de pausa a todos",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Forzar la sincronización de todos los usuarios",
"OPTION_JUMP_TO_OTHERS": "Unirse a otros",
"OPTION_JUMP_TO_ME": "Traerlos a mí",
"OPTION_JUMP_MODE_TOOLTIP": "Elegir el objetivo de sincronización",
"LABEL_LAST_ACTIVITY": "Última actividad",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Muestra el comando más reciente de reproducción, pausa o búsqueda",
"NO_RECENT_COMMANDS": "Sin comandos recientes",
"LOBBY_HEADER": "SALA DE ESPERA DE EPISODIO",
"LOBBY_WAITING_FOR": "🎬 Esperando a: \"{title}\"",
"LOBBY_WAITING_PEERS": "Esperando participantes...",
"BTN_SKIP_PLAY": "Omitir y reproducir",
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar la sala de espera y reproducir de todos modos",
"LOBBY_CONNECT_FIRST": "Únete a una sala primero",
"LOBBY_CONNECT_FIRST_DESC": "Necesitas unirte a una sala a través de un enlace de invitación o crear una nueva para sincronizar videos.",
"BTN_CREATE_ROOM_ALT": "Crear nueva sala",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crear una nueva sala aleatoria y unirse",
"LABEL_USERNAME": "Tu nombre de usuario",
"LABEL_USERNAME_TOOLTIP": "El nombre de usuario ayuda a otros a identificarte.",
"PLACEHOLDER_USERNAME": "Koala anónimo",
"LABEL_HIDE_CLUTTER": "Lista de pestañas limpia",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra pestañas que no son de video y dominios no relacionados para mantener limpia la lista",
"LABEL_AUTO_SYNC_NEXT": "Sincro auto del siguiente episodio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automáticamente y espera a todos al cambiar de episodio, luego inicia de forma sincrónica.",
"LABEL_AUTO_COPY_INVITE": "Auto-copiar enlace",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automáticamente el enlace de invitación a tu portapapeles al crear una nueva sala.",
"LABEL_NOTIFICATIONS": "Notificaciones del navegador",
"LABEL_NOTIFICATIONS_TOOLTIP": "Muestra notificaciones del sistema cuando alguien se une/sale o reproduce/pausa.",
"LABEL_LANGUAGE": "Idioma de la aplicación",
"LABEL_LANGUAGE_TOOLTIP": "Elige tu idioma preferido para la extensión",
"LABEL_TROUBLESHOOTING": "Resolución de problemas",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Herramientas para solucionar problemas de conexión",
"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",
"BTN_RETRY": "REINTENTAR",
"BTN_RETRY_TOOLTIP": "Intentar volver a conectarse al servidor",
"BTN_COPY_LOGS": "Copiar registros",
"BTN_COPY_LOGS_TOOLTIP": "Copiar registros al portapapeles para compartir",
"LABEL_VIDEO_DEBUG": "Información de depuración de video",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalles técnicos sobre el elemento de video seleccionado actualmente",
"VIDEO_DEBUG_EMPTY": "No hay pestaña seleccionada o no se detectó ningún video.",
"LABEL_HISTORY": "Historial completo",
"LABEL_HISTORY_TOOLTIP": "Registro cronológico de todos los comandos de sincronización en la sala",
"HISTORY_EMPTY": "Sin actividad aún",
"LABEL_LOGS": "Logs (Últimos 50)",
"LABEL_LOGS_TOOLTIP": "Registros técnicos de conexión para depuración",
"BTN_CLEAR": "LIMPIAR",
"BTN_CLEAR_TOOLTIP": "Limpiar salida de registros",
"LABEL_GITHUB": "Repositorio de GitHub",
"BTN_ONBOARDING_SKIP": "Omitir",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Omitir el tutorial",
"BTN_ONBOARDING_NEXT": "Siguiente",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir al siguiente paso",
"ONBOARDING_1_TITLE": "¡Bienvenido a KoalaSync!",
"ONBOARDING_1_TEXT": "Mira videos juntos en perfecta sincronización, sin importar dónde estén. ¡Hagamos un recorrido rápido!",
"ONBOARDING_2_TITLE": "1. Crear una sala",
"ONBOARDING_2_TEXT": "Comienza aquí. Crea una sala y comparte el enlace de invitación con tus amigos.",
"ONBOARDING_3_TITLE": "2. Seleccionar video",
"ONBOARDING_3_TEXT": "Navega aquí para seleccionar el video que deseas sincronizar. Reproduce, pausa y busca: todos permanecen sincronizados.",
"ONBOARDING_4_TITLE": "3. Personalizar",
"ONBOARDING_4_TEXT": "Elige un nombre de usuario divertido para que tus amigos sepan quién eres.",
"ONBOARDING_5_TITLE": "¡Todo listo!",
"ONBOARDING_5_TEXT": "Hora de preparar las palomitas. ¡Disfruta viendo con tus amigos!",
"ERR_CONN_TIMEOUT": "Tiempo de conexión agotado. Inténtalo de nuevo.",
"ERR_INVALID_SERVER_URL": "Formato de URL de servidor no válido.",
"ERR_IDENTITY_NOT_LOADED": "Identidad no cargada aún. Espera un momento e inténtalo de nuevo.",
"ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mí'.",
"ERR_NO_VIDEO_TAB": "No se pudo conectar a la pestaña de video.",
"ERR_SELECT_VIDEO": "¡Selecciona un video primero!",
"TOAST_INVITE_COPIED": "¡Enlace de invitación copiado!",
"TOAST_COPY_FAILED": "Error al copiar al portapapeles",
"TOAST_LOBBY_SKIPPED": "Sala de espera de episodio omitida.",
"TOAST_LOBBY_SKIP_FAILED": "Error al omitir la sala de espera.",
"TOAST_LOGS_COPIED": "¡Copiado!",
"TOAST_PEER_JOINED": "{name} se unió a la sala",
"TOAST_PEER_LEFT": "{name} salió de la sala",
"TOAST_PEER_ACTION": "{name} ha {action}",
"STATUS_CONNECTED": "Conectado",
"STATUS_RECONNECTING": "Reconectando...",
"STATUS_CONNECTING": "Conectando...",
"STATUS_FAILED": "Error",
"STATUS_DISCONNECTED": "Desconectado",
"BTN_STATE_JOINING": "🚀 Uniéndose...",
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
"BTN_STATE_PLAYING": "▶ Reproduciendo...",
"BTN_STATE_PAUSING": "⏸ Pausando...",
"BTN_STATE_SYNCING_GROUP": "Sincronizando al grupo ({time})...",
"BTN_STATE_SYNCING": "Sincronizando...",
"BTN_STATE_SYNCED": "✅ ¡Sincronizado!",
"NOTIF_PLAY": "inició la reproducción",
"NOTIF_PAUSE": "pausó la reproducción",
"NOTIF_SEEK": "cambió la posición del video",
"NOTIF_FORCE_PREPARE": "inició una sincronización forzada",
"NOTIF_FORCE_EXECUTE": "sincronizó a todos",
"DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.",
"DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.",
"EMPTY_PEERS_TITLE": "Sin participantes aún",
"EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar",
"EMPTY_HISTORY_TITLE": "Sin actividad aún",
"EMPTY_HISTORY_HINT": "Reproduce, pausa o busca para ver el historial",
"EMPTY_LOGS_TITLE": "Sin registros",
"EMPTY_LOGS_HINT": "Los eventos de conexión aparecerán aquí",
"EMPTY_ROOMS_TITLE": "Sin salas activas",
"EMPTY_ROOMS_HINT": "Crea una sala o actualiza para buscar salas públicas",
"LABEL_YOU": "Tú",
"ONBOARDING_DONE": "¡Hecho!",
"LABEL_LOBBY_PEER_READY": "Listo",
"LABEL_LOBBY_PEER_LOADING": "Cargando...",
"LABEL_PASSWORD_PROTECTED": "Protegido con contraseña",
"LABEL_PEERS_COUNT": "{count} participantes",
"LABEL_CUSTOM_SERVER": "Servidor personalizado",
"BTN_STATE_CREATING": "🚀 Creando sala...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Error al sincronizar episodio",
"NOTIF_LOBBY_CANCEL_MSG": "Sincronización automática cancelada: {reason}. Es posible que debas sincronizar manualmente.",
"LOBBY_CANCEL_TIMEOUT": "Tiempo de espera agotado",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tiempo de espera agotado (recuperado)",
"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",
"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"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "fr",
"HTML_CLASS": "lang-fr",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Salon",
"TAB_ROOM_TOOLTIP": "Paramètres de salon et connexion",
"TAB_SYNC": "Synchro",
"TAB_SYNC_TOOLTIP": "Contrôles de synchronisation vidéo et actions",
"TAB_SETTINGS": "Options",
"TAB_SETTINGS_TOOLTIP": "Préférences de l'extension",
"TAB_STATUS": "Statut",
"TAB_STATUS_TOOLTIP": "Diagnostics avancés & Journaux",
"BTN_CREATE_ROOM": "+ Créer un nouveau salon",
"MANUAL_CONNECT_HEADER": "Connexion manuelle / Avancé",
"LABEL_SERVER": "Serveur",
"BTN_SERVER_OFFICIAL": "Officiel",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Utiliser le serveur officiel fiable",
"BTN_SERVER_CUSTOM": "Perso",
"BTN_SERVER_CUSTOM_TOOLTIP": "Se connecter à votre propre serveur auto-hébergé",
"PLACEHOLDER_SERVER_URL": "wss://votre-serveur:3000",
"LABEL_ROOM_ID": "ID du salon",
"LABEL_ROOM_ID_TOOLTIP": "L'identifiant unique de votre salon de synchronisation",
"PLACEHOLDER_ROOM_ID": "Entrer l'ID du salon",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID unique du salon que vous souhaitez rejoindre",
"LABEL_PASSWORD": "Mot de passe (Optionnel)",
"LABEL_PASSWORD_TOOLTIP": "Mot de passe optionnel pour restreindre l'accès au salon",
"PLACEHOLDER_PASSWORD": "Mot de passe du salon (optionnel)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Mot de passe du salon (laisser vide si aucun)",
"BTN_JOIN_ROOM": "Rejoindre / Créer le salon",
"BTN_JOIN_ROOM_TOOLTIP": "Se connecter au salon",
"LABEL_PUBLIC_ROOMS": "Salons publics",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Liste des salons publics disponibles sur ce serveur",
"BTN_REFRESH": "ACTUALISER",
"BTN_REFRESH_TOOLTIP": "Actualiser la liste des salons publics",
"PUBLIC_ROOMS_REFRESHING": "Actualisation...",
"BTN_REFRESH_COOLDOWN": "ATTENDRE {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "La liste des salons est en pause. Réessayez dans {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualisation des salons publics. Prochaine actualisation dans {seconds}s.",
"LABEL_ACTIVE_ROOM": "Salon actif",
"LABEL_ACTIVE_ROOM_TOOLTIP": "Le salon auquel vous êtes actuellement connecté",
"ACTIVE_ROOM_NONE": "AUCUN",
"ACTIVE_SERVER_OFFICIAL": "Serveur officiel",
"LABEL_INVITE_LINK": "Lien d'invitation",
"LABEL_INVITE_LINK_TOOLTIP": "Partagez ce lien avec vos amis pour qu'ils vous rejoignent",
"LABEL_PEERS_IN_ROOM": "Membres dans le salon",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Autres utilisateurs connectés à ce salon",
"NO_PEERS_CONNECTED": "Aucun membre connecté",
"BTN_LEAVE_ROOM": "Quitter le salon",
"LABEL_SELECT_VIDEO": "Choisir une vidéo",
"LABEL_SELECT_VIDEO_TOOLTIP": "Choisissez l'onglet du navigateur contenant la vidéo à synchroniser",
"OPTION_SELECT_TAB": "-- Choisir un onglet --",
"LABEL_REMOTE_CONTROL": "Contrôle à distance",
"BTN_COPY_INVITE": "📋 Lien d'invitation",
"BTN_COPY_INVITE_TOOLTIP": "Copier le lien d'invitation",
"BTN_PLAY": "▶ Lecture",
"BTN_PLAY_TOOLTIP": "Envoyer une commande Lecture à tout le monde",
"BTN_PAUSE": "⏸ Pause",
"BTN_PAUSE_TOOLTIP": "Envoyer une commande Pause à tout le monde",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Forcer tous les utilisateurs à se synchroniser",
"OPTION_JUMP_TO_OTHERS": "Rejoindre les autres",
"OPTION_JUMP_TO_ME": "Les amener à moi",
"OPTION_JUMP_MODE_TOOLTIP": "Choisir la cible de synchronisation",
"LABEL_LAST_ACTIVITY": "Dernière activité",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Affiche la dernière commande de lecture, pause ou recherche",
"NO_RECENT_COMMANDS": "Aucune commande récente",
"LOBBY_HEADER": "LOBBY D'ÉPISODE",
"LOBBY_WAITING_FOR": "🎬 En attente de : \"{title}\"",
"LOBBY_WAITING_PEERS": "En attente des membres...",
"BTN_SKIP_PLAY": "Ignorer & Lancer",
"BTN_SKIP_PLAY_TOOLTIP": "Annuler le lobby et lancer la lecture quand même",
"LOBBY_CONNECT_FIRST": "Rejoignez d'abord un salon",
"LOBBY_CONNECT_FIRST_DESC": "Vous devez rejoindre un salon via un lien d'invitation ou en créer un nouveau pour synchroniser des vidéos.",
"BTN_CREATE_ROOM_ALT": "Créer un nouveau salon",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Créer un salon aléatoire et le rejoindre",
"LABEL_USERNAME": "Votre pseudo",
"LABEL_USERNAME_TOOLTIP": "Votre pseudo permet aux autres de vous identifier.",
"PLACEHOLDER_USERNAME": "Koala anonyme",
"LABEL_HIDE_CLUTTER": "Liste d'onglets épurée",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtre les onglets non-vidéo et les domaines non pertinents pour garder la liste propre",
"LABEL_AUTO_SYNC_NEXT": "Synchro auto l'épisode suivant",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Met en pause et attend tous les membres lors d'un changement d'épisode, puis démarre de manière synchrone.",
"LABEL_AUTO_COPY_INVITE": "Copie auto du lien",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copie automatiquement le lien d'invitation dans votre presse-papiers lors de la création d'un salon.",
"LABEL_NOTIFICATIONS": "Notifications de navigateur",
"LABEL_NOTIFICATIONS_TOOLTIP": "Affiche des notifications système lorsqu'un membre arrive/part ou lance/met en pause la lecture.",
"LABEL_LANGUAGE": "Langue de l'application",
"LABEL_LANGUAGE_TOOLTIP": "Choisissez la langue de l'application",
"LABEL_TROUBLESHOOTING": "Dépannage",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Outils pour résoudre les problèmes de connexion",
"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é",
"BTN_RETRY": "RÉESSAYER",
"BTN_RETRY_TOOLTIP": "Tenter de se reconnecter au serveur",
"BTN_COPY_LOGS": "Copier les journaux",
"BTN_COPY_LOGS_TOOLTIP": "Copier les journaux dans le presse-papiers",
"LABEL_VIDEO_DEBUG": "Infos de débogage vidéo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Détails techniques de l'élément vidéo actuellement sélectionné",
"VIDEO_DEBUG_EMPTY": "Aucun onglet sélectionné ou aucune vidéo détectée.",
"LABEL_HISTORY": "Historique complet",
"LABEL_HISTORY_TOOLTIP": "Historique chronologique de toutes les commandes de synchronisation dans le salon",
"HISTORY_EMPTY": "Aucune activité pour le moment",
"LABEL_LOGS": "Journaux (50 derniers)",
"LABEL_LOGS_TOOLTIP": "Journaux techniques pour le débogage de la connexion",
"BTN_CLEAR": "EFFACER",
"BTN_CLEAR_TOOLTIP": "Effacer la sortie des journaux",
"LABEL_GITHUB": "Dépôt GitHub",
"BTN_ONBOARDING_SKIP": "Passer",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Passer le didacticiel",
"BTN_ONBOARDING_NEXT": "Suivant",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Étape suivante",
"ONBOARDING_1_TITLE": "Bienvenue sur KoalaSync !",
"ONBOARDING_1_TEXT": "Regardez des vidéos en parfaite synchronisation — où que vous soyez. Faisons un petit tour !",
"ONBOARDING_2_TITLE": "1. Créer un salon",
"ONBOARDING_2_TEXT": "Commencez ici. Créez un salon et partagez le lien d'invitation avec vos amis.",
"ONBOARDING_3_TITLE": "2. Choisir une vidéo",
"ONBOARDING_3_TEXT": "Naviguez ici pour sélectionner la vidéo à synchroniser. Lecture, pause, recherche — tout le monde reste synchrone.",
"ONBOARDING_4_TITLE": "3. Personnaliser",
"ONBOARDING_4_TEXT": "Choisissez un pseudo sympa pour que vos amis sachent qui vous êtes.",
"ONBOARDING_5_TITLE": "Vous êtes prêt !",
"ONBOARDING_5_TEXT": "Il est temps de préparer le pop-corn. Bon visionnage !",
"ERR_CONN_TIMEOUT": "Délai de connexion dépassé. Veuillez réessayer.",
"ERR_INVALID_SERVER_URL": "Format d'adresse de serveur non valide.",
"ERR_IDENTITY_NOT_LOADED": "Identifiant non encore chargé. Veuillez patienter et réessayer.",
"ERR_NO_PEERS_TIME": "Aucun autre membre avec une position connue. Basculez sur 'Les amener à moi'.",
"ERR_NO_VIDEO_TAB": "Impossible de se connecter à l'onglet vidéo.",
"ERR_SELECT_VIDEO": "Veuillez d'abord sélectionner une vidéo !",
"TOAST_INVITE_COPIED": "Lien d'invitation copié !",
"TOAST_COPY_FAILED": "Échec de la copie dans le presse-papiers",
"TOAST_LOBBY_SKIPPED": "Lobby d'épisode ignoré.",
"TOAST_LOBBY_SKIP_FAILED": "Échec de l'annulation du lobby.",
"TOAST_LOGS_COPIED": "Copié !",
"TOAST_PEER_JOINED": "{name} a rejoint le salon",
"TOAST_PEER_LEFT": "{name} a quitté le salon",
"TOAST_PEER_ACTION": "{name} a {action}",
"STATUS_CONNECTED": "Connecté",
"STATUS_RECONNECTING": "Reconnexion...",
"STATUS_CONNECTING": "Connexion...",
"STATUS_FAILED": "Échec",
"STATUS_DISCONNECTED": "Déconnecté",
"BTN_STATE_JOINING": "🚀 Connexion...",
"BTN_STATE_RECONNECTING": "🔄 Reconnexion...",
"BTN_STATE_PLAYING": "▶ Lecture...",
"BTN_STATE_PAUSING": "⏸ Pause...",
"BTN_STATE_SYNCING_GROUP": "Synchro au groupe ({time})...",
"BTN_STATE_SYNCING": "Synchronisation...",
"BTN_STATE_SYNCED": "✅ Synchronisé !",
"NOTIF_PLAY": "lancé la lecture",
"NOTIF_PAUSE": "mis la lecture en pause",
"NOTIF_SEEK": "déplacé la position dans la vidéo",
"NOTIF_FORCE_PREPARE": "lancé une synchronisation forcée",
"NOTIF_FORCE_EXECUTE": "synchronisé tout le monde",
"DEBUG_NO_TAB": "Aucun onglet cible sélectionné.",
"DEBUG_COMM_FAIL": "Impossible de communiquer avec l'onglet vidéo.",
"EMPTY_PEERS_TITLE": "Aucun membre pour l'instant",
"EMPTY_PEERS_HINT": "Partagez votre lien d'invitation pour commencer",
"EMPTY_HISTORY_TITLE": "Aucune activité pour l'instant",
"EMPTY_HISTORY_HINT": "Lancez, mettez en pause ou déplacez la position pour voir l'historique",
"EMPTY_LOGS_TITLE": "Aucun journal",
"EMPTY_LOGS_HINT": "Les événements de connexion s'afficheront ici",
"EMPTY_ROOMS_TITLE": "Aucun salon actif",
"EMPTY_ROOMS_HINT": "Créez un salon ou actualisez pour trouver des salons publics",
"LABEL_YOU": "Vous",
"ONBOARDING_DONE": "Terminé !",
"LABEL_LOBBY_PEER_READY": "Prêt",
"LABEL_LOBBY_PEER_LOADING": "Chargement...",
"LABEL_PASSWORD_PROTECTED": "Protégé par mot de passe",
"LABEL_PEERS_COUNT": "{count} membres",
"LABEL_CUSTOM_SERVER": "Serveur personnalisé",
"BTN_STATE_CREATING": "🚀 Création de la salle...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Échec de la synchronisation de l'épisode",
"NOTIF_LOBBY_CANCEL_MSG": "Synchronisation automatique annulée : {reason}. Vous devrez peut-être synchroniser manuellement.",
"LOBBY_CANCEL_TIMEOUT": "Délai d'attente dépassé",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Délai dépassé (récupéré)",
"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",
"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"
}
+204
View File
@@ -0,0 +1,204 @@
# KoalaSync Browser Extension v2.0 - i18n Technical Implementation Plan
Welcome, future Antigravity AI agent! This document is placed directly in the codebase at `/extension/locales/i18n_plan.md` to serve as a comprehensive architectural handbook for the next steps in adding **full internationalization (i18n) support to the browser extension itself** while maintaining 100% video-sync and background communication safety.
---
## 🔍 Context & Audited Scope
KoalaSync is a lightweight, premium browser extension (Chrome & Firefox Manifest V3) for synchronized video playback. The landing pages are already compiled dynamically in 6 languages:
* English (`en`)
* German (`de`)
* French (`fr`)
* Spanish (`es`)
* Portuguese (Brazil) (`pt-BR`)
* Russian (`ru`)
Our goal is to build an identical, premium translation engine for the extension itself.
---
## 🏛️ Architectural Choice: Custom JSON Dictionary Engine (Approach B)
We evaluated two paths:
1. **Approach A (Native `chrome.i18n` API):** Uses `_locales/` directory. Too rigid—cannot support real-time dynamic switching inside the extension Settings dropdown without closing/re-opening the popup.
2. **Approach B (Custom Unified JSON Engine):** Uses flat JSON files matching our website files (`"KEY": "Value"`). Dynamically merges the target dictionary with baseline English `en.json` at runtime, programmatically providing an **airtight English fallback** and **real-time DOM translations** without popup reload.
We chose **Approach B** for maximum compatibility, premium real-time toggling, and clean fallback safety.
---
## 🔄 Resolve, Load, & State Flow
1. **On launch:** Look for saved language in `chrome.storage.sync.get('locale')`.
2. **Fallback Autodetect:** If no saved language, read `navigator.language` or `chrome.i18n.getUILanguage()`.
* If the detected locale is supported, set as active.
* If not supported, default to English (`en`).
3. **Dictionary Resolution:**
* Asynchronously load the English baseline dictionary (`/extension/locales/en.json`).
* If the target language is different, load target JSON (e.g. `/extension/locales/de.json`) and execute `Object.assign({}, enDict, targetDict)`. This guarantees dynamic translation while cleanly falling back to English for any missing keys.
4. **DOM Replacements:** Scan for `data-i18n`, `data-i18n-title`, and `data-i18n-placeholder` attributes, and translate them on the fly.
5. **Persistence:** Save dynamic selection modifications from the dropdown into `chrome.storage.sync`. Trigger instant DOM re-translation on change.
---
## 📂 Proposed File Structure
```
KoalaPlay/
└── extension/
├── locales/ # [NEW] Contains flat translation maps
│ ├── i18n_plan.md # This roadmap file
│ ├── en.json # Flat English baseline keys
│ ├── de.json # German keys
│ ├── fr.json # French keys
│ ├── es.json # Spanish keys
│ ├── pt-BR.json # Portuguese (Brasil) keys
│ └── ru.json # Russian keys
├── i18n.js # [NEW] ESM translation engine module
├── popup.html # Modified with data-i18n attributes
├── popup.js # Modified to initialize locales and update variables
└── background.js # Modified to push localized notification alerts
```
---
## 🛠️ Draft Code Snippets
### 1. `i18n.js` (Zero-Dependency Engine Module)
```javascript
// extension/i18n.js
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
export const DEFAULT_LANGUAGE = 'en';
let activeDictionary = {};
export async function loadLocale(langCode) {
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
try {
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
const enDict = await enResponse.json();
if (resolvedLang === DEFAULT_LANGUAGE) {
activeDictionary = enDict;
return;
}
const targetResponse = await fetch(chrome.runtime.getURL(`locales/${resolvedLang}.json`));
const targetDict = await targetResponse.json();
activeDictionary = Object.assign({}, enDict, targetDict);
} catch (err) {
console.error('[i18n] Failed to load dictionary. Falling back to English:', err);
const rescue = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
activeDictionary = await rescue.json();
}
}
export function getMessage(key) {
return activeDictionary[key] || key;
}
export function translateDOM() {
// Translate text nodes
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const translated = getMessage(key);
const img = el.querySelector('img');
if (img) {
el.innerHTML = '';
el.appendChild(img);
el.appendChild(document.createTextNode(' ' + translated));
} else {
el.textContent = translated;
}
});
// Translate tooltips
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.setAttribute('title', getMessage(key));
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.setAttribute('placeholder', getMessage(key));
});
}
```
### 2. Markup Changes (`popup.html`)
* Annotate text elements: `<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP">Settings</button>`
* Add Language Dropdown in the Settings Panel:
```html
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;" data-i18n="LABEL_LANGUAGE" title="Choose your preferred extension language">App Language</label>
<select id="langSelector" style="width: 150px; padding: 6px 10px; font-size: 13px; cursor: pointer;">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="pt-BR">Português (Brasil)</option>
<option value="ru">Русский</option>
</select>
</div>
```
### 3. Dynamic Script Wiring (`popup.js`)
* Import our engine: `import { loadLocale, translateDOM, getMessage } from './i18n.js';`
* Initialize during startup:
```javascript
async function init() {
const data = await chrome.storage.sync.get(['locale', ...]);
let activeLang = data.locale;
if (!activeLang) {
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
chrome.storage.sync.set({ locale: activeLang });
}
await loadLocale(activeLang);
translateDOM();
// Select matching option in selector
const langSelector = document.getElementById('langSelector');
if (langSelector) langSelector.value = activeLang;
// rest of standard init...
}
```
* Listen to Settings change event:
```javascript
const langSelector = document.getElementById('langSelector');
if (langSelector) {
langSelector.addEventListener('change', async () => {
const selectedLang = langSelector.value;
await chrome.storage.sync.set({ locale: selectedLang });
await loadLocale(selectedLang);
translateDOM();
// Re-render empty elements and tab list using new dynamic strings
refreshLogs();
refreshHistory();
populateTabs();
});
}
```
### 4. Background Notifications (`background.js`)
* Notifications inside `showNotification` should fetch dynamic keys based on `chrome.storage.sync` language settings and build clean alerts dynamically using `chrome.storage.sync.get('locale')`.
* Ensure that WebSocket event transmissions and video player scripts in `content.js` remain **completely untouched and unaware** of the localization engine.
---
## 🔒 Safety Guardrail
* **WebSocket Engine Integrity:** Under no circumstances should `background.js` Socket.IO handshakes, rate limits, or binary protocol headers be adjusted. i18n is strictly an interface rendering skin layer.
* **Player Control Pipeline Integrity:** `content.js` expectedEvent suppression tables, HLS buffer calculations, and Media Session API listeners must remain untouched. Do not bind any i18n logic into the content script's active tracking loop.
---
## 🧪 Verification Tasks
1. **Autodetection Audit:** Force-load the extension in Chrome/Firefox in a non-English language container and ensure it starts in that language or English gracefully.
2. **Integrity Validation Script:** Write a sanity node checker script `/scripts/test-locales.js` to ensure all key structures are uniform across all JSON files.
3. **Real-Time Refresh Verification:** Change dynamic drop-down preferences and verify that every element and alert swaps languages cleanly with zero redraw glitches or page crashes.
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "it",
"HTML_CLASS": "lang-it",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Stanza",
"TAB_ROOM_TOOLTIP": "Impostazioni della stanza e connessione",
"TAB_SYNC": "Sincronizza",
"TAB_SYNC_TOOLTIP": "Controlli di sincronizzazione video e azioni remote",
"TAB_SETTINGS": "Impostazioni",
"TAB_SETTINGS_TOOLTIP": "Preferenze dell'estensione",
"TAB_STATUS": "Stato",
"TAB_STATUS_TOOLTIP": "Diagnostica avanzata e log",
"BTN_CREATE_ROOM": "+ Crea Nuova Stanza",
"MANUAL_CONNECT_HEADER": "Connessione Manuale / Avanzata",
"LABEL_SERVER": "Server",
"BTN_SERVER_OFFICIAL": "Ufficiale",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usa il server ufficiale affidabile",
"BTN_SERVER_CUSTOM": "Personalizzato",
"BTN_SERVER_CUSTOM_TOOLTIP": "Connettiti al tuo server ospitato autonomamente",
"PLACEHOLDER_SERVER_URL": "wss://tuo-server:3000",
"LABEL_ROOM_ID": "ID Stanza",
"LABEL_ROOM_ID_TOOLTIP": "L'identificatore univoco per la tua stanza di sincronizzazione",
"PLACEHOLDER_ROOM_ID": "Inserisci ID Stanza",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID univoco della stanza a cui vuoi unirti",
"LABEL_PASSWORD": "Password (Opzionale)",
"LABEL_PASSWORD_TOOLTIP": "Password opzionale per limitare l'accesso alla stanza",
"PLACEHOLDER_PASSWORD": "Password della stanza (opzionale)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password per la stanza (lascia vuoto se nessuna)",
"BTN_JOIN_ROOM": "Entra / Crea Stanza",
"BTN_JOIN_ROOM_TOOLTIP": "Connettiti alla stanza",
"LABEL_PUBLIC_ROOMS": "Stanze Pubbliche",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubblicamente disponibili su questo server",
"BTN_REFRESH": "AGGIORNA",
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze pubbliche",
"PUBLIC_ROOMS_REFRESHING": "Aggiornamento...",
"BTN_REFRESH_COOLDOWN": "ATTENDI {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "L'aggiornamento dell'elenco è in pausa. Riprova tra {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento stanze pubbliche. Prossimo aggiornamento disponibile tra {seconds}s.",
"LABEL_ACTIVE_ROOM": "Stanza Attiva",
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei attualmente connesso",
"ACTIVE_ROOM_NONE": "NESSUNA",
"ACTIVE_SERVER_OFFICIAL": "Server Ufficiale",
"LABEL_INVITE_LINK": "Link di Invito",
"LABEL_INVITE_LINK_TOOLTIP": "Condividi questo link con gli amici per farli entrare",
"LABEL_PEERS_IN_ROOM": "Partecipanti nella Stanza",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Altri utenti attualmente connessi a questa stanza",
"NO_PEERS_CONNECTED": "Nessun partecipante connesso",
"BTN_LEAVE_ROOM": "Lascia Stanza",
"LABEL_SELECT_VIDEO": "Seleziona Video",
"LABEL_SELECT_VIDEO_TOOLTIP": "Scegli la scheda del browser contenente il video da sincronizzare",
"OPTION_SELECT_TAB": "-- Seleziona una Scheda --",
"LABEL_REMOTE_CONTROL": "Controllo Remoto",
"BTN_COPY_INVITE": "📋 Link di Invito",
"BTN_COPY_INVITE_TOOLTIP": "Copia Link di Invito",
"BTN_PLAY": "▶ Riproduci",
"BTN_PLAY_TOOLTIP": "Invia un comando di riproduzione a tutti",
"BTN_PAUSE": "⏸ Pausa",
"BTN_PAUSE_TOOLTIP": "Invia un comando di pausa a tutti",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione di tutti gli utenti",
"OPTION_JUMP_TO_OTHERS": "Salto dagli Altri",
"OPTION_JUMP_TO_ME": "Salto da Me",
"OPTION_JUMP_MODE_TOOLTIP": "Scegli il target di sincronizzazione",
"LABEL_LAST_ACTIVITY": "Stato Ultima Attività",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra il comando più recente di riproduzione, pausa o ricerca",
"NO_RECENT_COMMANDS": "Nessun comando recente",
"LOBBY_HEADER": "LOBBY EPISODIO",
"LOBBY_WAITING_FOR": "🎬 In attesa di: \"{title}\"",
"LOBBY_WAITING_PEERS": "In attesa dei partecipanti...",
"BTN_SKIP_PLAY": "Salta e riproduci comunque",
"BTN_SKIP_PLAY_TOOLTIP": "Annulla la lobby e riproduci comunque",
"LOBBY_CONNECT_FIRST": "Connettiti prima a una stanza",
"LOBBY_CONNECT_FIRST_DESC": "Devi unirti a una stanza tramite un link di invito o crearne una nuova per sincronizzare i video.",
"BTN_CREATE_ROOM_ALT": "Crea Nuova Stanza",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crea una nuova stanza casuale ed entra",
"LABEL_USERNAME": "Tuo Nome Utente",
"LABEL_USERNAME_TOOLTIP": "Il nome utente aiuta gli altri a identificarti.",
"PLACEHOLDER_USERNAME": "Koala Anonimo",
"LABEL_HIDE_CLUTTER": "Nascondi Schede Inutili",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra le schede senza video e i domini non correlati per mantenere pulito l'elenco",
"LABEL_AUTO_SYNC_NEXT": "Auto-Sync Prossimo Episodio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e attende tutti i partecipanti al cambio episodio, poi si avvia in sincro.",
"LABEL_AUTO_COPY_INVITE": "Copia Automatica Invito",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente il link di invito negli appunti quando crei una nuova stanza.",
"LABEL_NOTIFICATIONS": "Notifiche del Browser",
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra le notifiche di sistema quando qualcuno entra/esce o riproduce/mette in pausa.",
"LABEL_LANGUAGE": "Lingua Applicazione",
"LABEL_LANGUAGE_TOOLTIP": "Scegli la lingua preferita per l'estensione",
"LABEL_TROUBLESHOOTING": "Risoluzione Problemi",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Strumenti per risolvere problemi di connessione",
"BTN_REGEN_ID": "Rigenera ID Peer",
"BTN_REGEN_ID_TOOLTIP": "Rigenera il tuo ID interno e riconnettiti",
"REGEN_ID_DESC": "Usa questo se riscontri errori di \"Identità Duplicata\".",
"REGEN_ID_OTHER_ISSUE": "Altro problema? Apri una Issue su GitHub",
"LABEL_CONN_STATUS": "Stato Connessione",
"LABEL_CONN_STATUS_TOOLTIP": "Stato corrente della connessione WebSocket",
"CONN_STATUS_DISCONNECTED": "Disconnesso",
"BTN_RETRY": "RIPROVA",
"BTN_RETRY_TOOLTIP": "Tenta di riconnettersi al server",
"BTN_COPY_LOGS": "Copia Log",
"BTN_COPY_LOGS_TOOLTIP": "Copia i log negli appunti per la condivisione",
"LABEL_VIDEO_DEBUG": "Info Debug Video",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Dettagli tecnici sull'elemento video attualmente selezionato",
"VIDEO_DEBUG_EMPTY": "Nessuna scheda selezionata o video rilevato.",
"LABEL_HISTORY": "Cronologia Azioni Completa",
"LABEL_HISTORY_TOOLTIP": "Registro cronologico di tutti i comandi di sincronizzazione nella stanza",
"HISTORY_EMPTY": "Ancora nessuna attività",
"LABEL_LOGS": "Log (Ultimi 50)",
"LABEL_LOGS_TOOLTIP": "Log di connessione tecnica per il debug",
"BTN_CLEAR": "PULISCI",
"BTN_CLEAR_TOOLTIP": "Cancella l'output del log",
"LABEL_GITHUB": "Repository GitHub",
"BTN_ONBOARDING_SKIP": "Salta",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Salta il tutorial",
"BTN_ONBOARDING_NEXT": "Avanti",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Vai al passaggio successivo",
"ONBOARDING_1_TITLE": "Benvenuto in KoalaSync!",
"ONBOARDING_1_TEXT": "Guarda i video insieme in perfetta sincronia, non importa dove ti trovi. Facciamo un rapido tour!",
"ONBOARDING_2_TITLE": "1. Crea una Stanza",
"ONBOARDING_2_TEXT": "Inizia qui. Crea una stanza e condividi il link di invito con i tuoi amici.",
"ONBOARDING_3_TITLE": "2. Seleziona Video",
"ONBOARDING_3_TEXT": "Naviga qui per selezionare il video che vuoi sincronizzare. Riproduci, metti in pausa e cerca: tutti rimangono sincronizzati.",
"ONBOARDING_4_TITLE": "3. Personalizza",
"ONBOARDING_4_TEXT": "Scegli un nome utente divertente così i tuoi amici sanno chi sei.",
"ONBOARDING_5_TITLE": "Tutto pronto!",
"ONBOARDING_5_TEXT": "È ora di prendere i popcorn. Buona visione insieme!",
"ERR_CONN_TIMEOUT": "Connessione scaduta. Riprova.",
"ERR_INVALID_SERVER_URL": "Formato URL del server non valido.",
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento e riprova.",
"ERR_NO_PEERS_TIME": "Nessun altro peer con un orario noto. Passa a 'Salto da Me'.",
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
"TOAST_INVITE_COPIED": "Link di invito copiato!",
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
"TOAST_LOBBY_SKIPPED": "Lobby dell'episodio saltata.",
"TOAST_LOBBY_SKIP_FAILED": "Impossibile saltare la lobby.",
"TOAST_LOGS_COPIED": "Copiato!",
"TOAST_PEER_JOINED": "{name} è entrato nella stanza",
"TOAST_PEER_LEFT": "{name} ha lasciato la stanza",
"TOAST_PEER_ACTION": "{name} ha {action}",
"STATUS_CONNECTED": "Connesso",
"STATUS_RECONNECTING": "Riconnessione...",
"STATUS_CONNECTING": "Connessione in corso...",
"STATUS_FAILED": "Fallito",
"STATUS_DISCONNECTED": "Disconnesso",
"BTN_STATE_JOINING": "🚀 Entrando...",
"BTN_STATE_RECONNECTING": "🔄 Riconnessione...",
"BTN_STATE_PLAYING": "▶ Riproduzione...",
"BTN_STATE_PAUSING": "⏸ In pausa...",
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione al gruppo ({time})...",
"BTN_STATE_SYNCING": "Sincronizzazione...",
"BTN_STATE_SYNCED": "✅ Sincronizzato!",
"NOTIF_PLAY": "avviato la riproduzione",
"NOTIF_PAUSE": "messo in pausa la riproduzione",
"NOTIF_SEEK": "cercato nel video",
"NOTIF_FORCE_PREPARE": "avviato la sincronizzazione forzata",
"NOTIF_FORCE_EXECUTE": "sincronizzato tutti",
"DEBUG_NO_TAB": "Nessuna scheda di destinazione selezionata.",
"DEBUG_COMM_FAIL": "Impossibile comunicare con il video della scheda.",
"EMPTY_PEERS_TITLE": "Ancora nessun partecipante",
"EMPTY_PEERS_HINT": "Condividi il tuo link di invito per iniziare",
"EMPTY_HISTORY_TITLE": "Ancora nessuna attività",
"EMPTY_HISTORY_HINT": "Riproduci, metti in pausa o cerca per vedere la cronologia",
"EMPTY_LOGS_TITLE": "Nessun log",
"EMPTY_LOGS_HINT": "Gli eventi di connessione appariranno qui",
"EMPTY_ROOMS_TITLE": "Nessuna stanza attiva",
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna per trovare quelle pubbliche",
"LABEL_YOU": "Tu",
"ONBOARDING_DONE": "Fatto!",
"LABEL_LOBBY_PEER_READY": "Pronto",
"LABEL_LOBBY_PEER_LOADING": "Caricamento...",
"LABEL_PASSWORD_PROTECTED": "Protetto da Password",
"LABEL_PEERS_COUNT": "{count} partecipanti",
"LABEL_CUSTOM_SERVER": "Server Personalizzato",
"BTN_STATE_CREATING": "🚀 Creazione Stanza...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronizzazione Episodio Fallita",
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync annullato: {reason}. Potrebbe essere necessaria una sincronizzazione manuale.",
"LOBBY_CANCEL_TIMEOUT": "Timeout",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (ripristinato)",
"LOBBY_CANCEL_PEERS_LEFT": "Tutti gli altri partecipanti hanno lasciato la stanza",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — non tutti i partecipanti hanno caricato l'episodio",
"LOBBY_CANCEL_USER": "Annullato dall'utente",
"NOTIF_ERROR_TITLE": "Errore KoalaSync",
"FOOTER_SUPPORT": "☕ Offrimi un caffè",
"FOOTER_REVIEW": "★ Valuta",
"FOOTER_SUPPORT_PROMPT": "Ti piace KoalaSync? Lascia una recensione!",
"LABEL_AUDIO_PROCESSING": "Elaborazione audio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti audio come la compressione alla riproduzione video",
"AUDIO_OPEN_SETTINGS": "Apri",
"NEW_FEATURE_AUDIO": "Novità: Elaborazione audio — prova il compressore!",
"AUDIO_BACK": "← Indietro",
"AUDIO_PAGE_TITLE": "Impostazioni audio",
"AUDIO_MASTER_TOGGLE": "Elaborazione audio",
"AUDIO_COMPRESSOR": "Compressore",
"AUDIO_COMPRESSOR_ENABLE": "Attivato",
"AUDIO_PRESET": "Preimpostazione",
"AUDIO_PRESET_RECOMMENDED": "Consigliato",
"AUDIO_PRESET_DYNAMIC_RANGE": "Gamma dinamica",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Miglioramento vocale",
"AUDIO_PRESET_SMOOTH": "Morbido",
"AUDIO_PRESET_CUSTOM": "Personalizzato",
"AUDIO_PARAM_THRESHOLD": "Soglia",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizzatore",
"AUDIO_COMING_SOON": "Prossimamente"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "ja",
"HTML_CLASS": "lang-ja",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "ルーム",
"TAB_ROOM_TOOLTIP": "ルーム設定と接続",
"TAB_SYNC": "同期",
"TAB_SYNC_TOOLTIP": "ビデオ同期コントロールとリモートアクション",
"TAB_SETTINGS": "設定",
"TAB_SETTINGS_TOOLTIP": "拡張機能の設定",
"TAB_STATUS": "ステータス",
"TAB_STATUS_TOOLTIP": "高度な診断とログ",
"BTN_CREATE_ROOM": "+ 新規ルーム作成",
"MANUAL_CONNECT_HEADER": "手動接続 / 詳細設定",
"LABEL_SERVER": "サーバー",
"BTN_SERVER_OFFICIAL": "公式",
"BTN_SERVER_OFFICIAL_TOOLTIP": "公式の信頼できるサーバーを使用",
"BTN_SERVER_CUSTOM": "カスタム",
"BTN_SERVER_CUSTOM_TOOLTIP": "独自のセルフホストサーバーに接続",
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
"LABEL_ROOM_ID": "ルームID",
"LABEL_ROOM_ID_TOOLTIP": "同期ルームの固有の識別子",
"PLACEHOLDER_ROOM_ID": "ルームIDを入力",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "参加したいルームの固有のID",
"LABEL_PASSWORD": "パスワード(任意)",
"LABEL_PASSWORD_TOOLTIP": "ルームへのアクセスを制限するための任意のパスワード",
"PLACEHOLDER_PASSWORD": "ルームのパスワード(任意)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "ルームのパスワード(ない場合は空欄)",
"BTN_JOIN_ROOM": "ルームに参加 / 作成",
"BTN_JOIN_ROOM_TOOLTIP": "ルームに接続",
"LABEL_PUBLIC_ROOMS": "公開ルーム",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "このサーバー上の公開ルーム의リスト",
"BTN_REFRESH": "更新",
"BTN_REFRESH_TOOLTIP": "公開ルームのリストを更新",
"PUBLIC_ROOMS_REFRESHING": "更新中...",
"BTN_REFRESH_COOLDOWN": "{seconds}秒待機",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "ルームリスト更新のクールダウン中。{seconds}秒後に再試行してください。",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "公開ルームを更新中。次の更新まであと{seconds}秒。",
"LABEL_ACTIVE_ROOM": "アクティブなルーム",
"LABEL_ACTIVE_ROOM_TOOLTIP": "現在接続しているルーム",
"ACTIVE_ROOM_NONE": "なし",
"ACTIVE_SERVER_OFFICIAL": "公式サーバー",
"LABEL_INVITE_LINK": "招待リンク",
"LABEL_INVITE_LINK_TOOLTIP": "友達とこのリンクを共有して参加してもらいましょう",
"LABEL_PEERS_IN_ROOM": "ルーム内のメンバー",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "現在このルームに接続している他のユーザー",
"NO_PEERS_CONNECTED": "接続しているメンバーはいません",
"BTN_LEAVE_ROOM": "ルームを退室",
"LABEL_SELECT_VIDEO": "ビデオを選択",
"LABEL_SELECT_VIDEO_TOOLTIP": "同期するビデオが含まれるブラウザのタブを選択してください",
"OPTION_SELECT_TAB": "-- タブを選択してください --",
"LABEL_REMOTE_CONTROL": "リモートコントロール",
"BTN_COPY_INVITE": "📋 招待リンク",
"BTN_COPY_INVITE_TOOLTIP": "招待リンクをコピー",
"BTN_PLAY": "▶ 再生",
"BTN_PLAY_TOOLTIP": "全員に再生コマンドを送信",
"BTN_PAUSE": "⏸ 一時停止",
"BTN_PAUSE_TOOLTIP": "全員に一時停止コマンドを送信",
"BTN_SYNC": "⚡ 同期",
"BTN_SYNC_TOOLTIP": "すべてのユーザーを強制的に同期",
"OPTION_JUMP_TO_OTHERS": "他の人に合わせる",
"OPTION_JUMP_TO_ME": "自分に合わせる",
"OPTION_JUMP_MODE_TOOLTIP": "同期対象を選択",
"LABEL_LAST_ACTIVITY": "最新のアクティビティ状況",
"LABEL_LAST_ACTIVITY_TOOLTIP": "最新の再生、一時停止、またはシークコマンドを表示します",
"NO_RECENT_COMMANDS": "最近のコマンドはありません",
"LOBBY_HEADER": "エピソードロビー",
"LOBBY_WAITING_FOR": "🎬 待機中: \"{title}\"",
"LOBBY_WAITING_PEERS": "メンバーを待機中...",
"BTN_SKIP_PLAY": "スキップして再生",
"BTN_SKIP_PLAY_TOOLTIP": "ロビーをキャンセルして再生します",
"LOBBY_CONNECT_FIRST": "最初にルームに接続してください",
"LOBBY_CONNECT_FIRST_DESC": "ビデオを同期するには、招待リンクからルームに参加するか、新規に作成する必要があります。",
"BTN_CREATE_ROOM_ALT": "新規ルーム作成",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "ランダムな新規ルームを作成して参加",
"LABEL_USERNAME": "ユーザー名",
"LABEL_USERNAME_TOOLTIP": "ユーザー名は他のメンバーがあなたを識別するのに役立ちます。",
"PLACEHOLDER_USERNAME": "匿名コアラ",
"LABEL_HIDE_CLUTTER": "不要なタブを非表示",
"LABEL_HIDE_CLUTTER_TOOLTIP": "リストをすっきりさせるため、ビデオのないタブや無関係なドメインをフィルタリングします",
"LABEL_AUTO_SYNC_NEXT": "次のエピソードを自動同期",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "エピソード変更時に自動的に一時停止して全員を待ち、準備ができたら同時に再生を開始します。",
"LABEL_AUTO_COPY_INVITE": "招待リンク自動コピー",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "新規ルーム作成時に招待リンクをクリップボードに自動コピーします。",
"LABEL_NOTIFICATIONS": "ブラウザ通知",
"LABEL_NOTIFICATIONS_TOOLTIP": "誰かが参加/退室したとき、または再生/一時停止したときにシステムの通知を表示します。",
"LABEL_LANGUAGE": "アプリの言語",
"LABEL_LANGUAGE_TOOLTIP": "拡張機能の優先言語を選択してください",
"LABEL_TROUBLESHOOTING": "トラブルシューティング",
"LABEL_TROUBLESHOOTING_TOOLTIP": "接続の問題を修正するためのツール",
"BTN_REGEN_ID": "ピアIDの再生成",
"BTN_REGEN_ID_TOOLTIP": "内部IDを再生成して再接続します",
"REGEN_ID_DESC": "「Duplicate Identity」(ID重複)エラーが表示される場合に使用します。",
"REGEN_ID_OTHER_ISSUE": "他の問題?GitHub Issueを開く",
"LABEL_CONN_STATUS": "接続状態",
"LABEL_CONN_STATUS_TOOLTIP": "現在のWebSocket接続状態",
"CONN_STATUS_DISCONNECTED": "切断されました",
"BTN_RETRY": "再試行",
"BTN_RETRY_TOOLTIP": "サーバーへの再接続を試行",
"BTN_COPY_LOGS": "ログをコピー",
"BTN_COPY_LOGS_TOOLTIP": "共有用にログをクリップボードにコピー",
"LABEL_VIDEO_DEBUG": "ビデオデバッグ情報",
"LABEL_VIDEO_DEBUG_TOOLTIP": "現在選択されているビデオ要素に関する技術的詳細",
"VIDEO_DEBUG_EMPTY": "タブが選択されていないか、ビデオが検出されませんでした。",
"LABEL_HISTORY": "全アクション履歴",
"LABEL_HISTORY_TOOLTIP": "ルーム内のすべての同期コマンドの時系列ログ",
"HISTORY_EMPTY": "アクティビティはまだありません",
"LABEL_LOGS": "ログ(直近50件)",
"LABEL_LOGS_TOOLTIP": "デバッグ用の技術接続ログ",
"BTN_CLEAR": "消去",
"BTN_CLEAR_TOOLTIP": "ログ出力をクリア",
"LABEL_GITHUB": "GitHubリポジトリ",
"BTN_ONBOARDING_SKIP": "スキップ",
"BTN_ONBOARDING_SKIP_TOOLTIP": "チュートリアルをスキップ",
"BTN_ONBOARDING_NEXT": "次へ",
"BTN_ONBOARDING_NEXT_TOOLTIP": "次のステップに進む",
"ONBOARDING_1_TITLE": "KoalaSyncへようこそ!",
"ONBOARDING_1_TEXT": "どこにいても、完璧に同期してビデオを一緒に楽しめます。簡単なツアーを始めましょう!",
"ONBOARDING_2_TITLE": "1. ルームの作成",
"ONBOARDING_2_TEXT": "ここからスタート。ルームを作成し、招待リンクを友達に共有します。",
"ONBOARDING_3_TITLE": "2. ビデオの選択",
"ONBOARDING_3_TEXT": "ここで同期したいビデオを選択します。再生、一時停止、シーク操作は全員に同期されます。",
"ONBOARDING_4_TITLE": "3. カスタマイズ",
"ONBOARDING_4_TEXT": "友達にわかりやすいように、楽しいユーザー名を設定しましょう。",
"ONBOARDING_5_TITLE": "準備が整いました!",
"ONBOARDING_5_TEXT": "ポップコーンを用意しましょう。一緒に鑑賞を楽しんでください!",
"ERR_CONN_TIMEOUT": "接続がタイムアウトしました。再試行してください。",
"ERR_INVALID_SERVER_URL": "サーバーURLの形式が無効です。",
"ERR_IDENTITY_NOT_LOADED": "IDがまだロードされていません。しばらく待ってから再試行してください。",
"ERR_NO_PEERS_TIME": "既知の時間を持つ他のメンバーがいません。「自分に合わせる」に切り替えてください。",
"ERR_NO_VIDEO_TAB": "ビデオタブに接続できませんでした。",
"ERR_SELECT_VIDEO": "最初にビデオを選択してください!",
"TOAST_INVITE_COPIED": "招待リンクをコピーしました!",
"TOAST_COPY_FAILED": "クリップボードへのコピーに失敗しました",
"TOAST_LOBBY_SKIPPED": "エピソードロビーをスキップしました。",
"TOAST_LOBBY_SKIP_FAILED": "ロビーのスキップに失敗しました。",
"TOAST_LOGS_COPIED": "コピーしました!",
"TOAST_PEER_JOINED": "{name}が参加しました",
"TOAST_PEER_LEFT": "{name}が退室しました",
"TOAST_PEER_ACTION": "{name}が{action}",
"STATUS_CONNECTED": "接続完了",
"STATUS_RECONNECTING": "再接続中...",
"STATUS_CONNECTING": "接続中...",
"STATUS_FAILED": "失敗",
"STATUS_DISCONNECTED": "切断されました",
"BTN_STATE_JOINING": "🚀 参加中...",
"BTN_STATE_RECONNECTING": "🔄 再接続中...",
"BTN_STATE_PLAYING": "▶ 再生中...",
"BTN_STATE_PAUSING": "⏸ 一時停止中...",
"BTN_STATE_SYNCING_GROUP": "グループに同期中 ({time})...",
"BTN_STATE_SYNCING": "同期中...",
"BTN_STATE_SYNCED": "✅ 同期完了!",
"NOTIF_PLAY": "再生を開始しました",
"NOTIF_PAUSE": "再生を一時停止しました",
"NOTIF_SEEK": "動画をシークしました",
"NOTIF_FORCE_PREPARE": "強制同期を開始しました",
"NOTIF_FORCE_EXECUTE": "全員を同期しました",
"DEBUG_NO_TAB": "対象のタブが選択されていません。",
"DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。",
"EMPTY_PEERS_TITLE": "メンバーはまだいません",
"EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう",
"EMPTY_HISTORY_TITLE": "アクティビティはまだありません",
"EMPTY_HISTORY_HINT": "履歴を表示するには再生、一時停止、またはシークを行います",
"EMPTY_LOGS_TITLE": "ログはありません",
"EMPTY_LOGS_HINT": "接続イベントがここに表示されます",
"EMPTY_ROOMS_TITLE": "アクティブなルームはありません",
"EMPTY_ROOMS_HINT": "ルームを作成するか、更新して公開ルームを探します",
"LABEL_YOU": "あなた",
"ONBOARDING_DONE": "完了!",
"LABEL_LOBBY_PEER_READY": "準備完了",
"LABEL_LOBBY_PEER_LOADING": "読み込み中...",
"LABEL_PASSWORD_PROTECTED": "パスワード保護",
"LABEL_PEERS_COUNT": "{count}人",
"LABEL_CUSTOM_SERVER": "カスタムサーバー",
"BTN_STATE_CREATING": "🚀 ルーム作成中...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — エピソード同期に失敗しました",
"NOTIF_LOBBY_CANCEL_MSG": "自動同期がキャンセルされました: {reason}。手動で同期する必要がある場合があります。",
"LOBBY_CANCEL_TIMEOUT": "タイムアウト",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "タイムアウト(回復済)",
"LOBBY_CANCEL_PEERS_LEFT": "他のすべてのメンバーが退室しました",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "タイムアウト — 一部のメンバーがエピソードを読み込めませんでした",
"LOBBY_CANCEL_USER": "ユーザーによってキャンセルされました",
"NOTIF_ERROR_TITLE": "KoalaSyncエラー",
"FOOTER_SUPPORT": "☕ コーヒーをおごってね",
"FOOTER_REVIEW": "★ 評価する",
"FOOTER_SUPPORT_PROMPT": "KoalaSyncはいかが?レビューを書いてください!",
"LABEL_AUDIO_PROCESSING": "オーディオ処理",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "動画再生に圧縮などのオーディオエフェクトを適用します",
"AUDIO_OPEN_SETTINGS": "開く",
"NEW_FEATURE_AUDIO": "新機能: オーディオ処理 — コンプレッサーを試してみよう!",
"AUDIO_BACK": "← 戻る",
"AUDIO_PAGE_TITLE": "オーディオ設定",
"AUDIO_MASTER_TOGGLE": "オーディオ処理",
"AUDIO_COMPRESSOR": "コンプレッサー",
"AUDIO_COMPRESSOR_ENABLE": "有効",
"AUDIO_PRESET": "プリセット",
"AUDIO_PRESET_RECOMMENDED": "推奨",
"AUDIO_PRESET_DYNAMIC_RANGE": "ダイナミックレンジ",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "ボーカル強調",
"AUDIO_PRESET_SMOOTH": "スムース",
"AUDIO_PRESET_CUSTOM": "カスタム",
"AUDIO_PARAM_THRESHOLD": "スレッショルド",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "イコライザー",
"AUDIO_COMING_SOON": "近日公開"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "ko",
"HTML_CLASS": "lang-ko",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "방",
"TAB_ROOM_TOOLTIP": "방 설정 및 연결",
"TAB_SYNC": "동기화",
"TAB_SYNC_TOOLTIP": "비디오 동기화 제어 및 원격 작업",
"TAB_SETTINGS": "설정",
"TAB_SETTINGS_TOOLTIP": "확장 프로그램 기본 설정",
"TAB_STATUS": "상태",
"TAB_STATUS_TOOLTIP": "고급 진단 및 로그",
"BTN_CREATE_ROOM": "+ 새 방 만들기",
"MANUAL_CONNECT_HEADER": "수동 연결 / 고급",
"LABEL_SERVER": "서버",
"BTN_SERVER_OFFICIAL": "공식",
"BTN_SERVER_OFFICIAL_TOOLTIP": "공식적이고 신뢰할 수 있는 서버 사용",
"BTN_SERVER_CUSTOM": "사용자 정의",
"BTN_SERVER_CUSTOM_TOOLTIP": "자체 호스팅 서버에 연결",
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
"LABEL_ROOM_ID": "방 ID",
"LABEL_ROOM_ID_TOOLTIP": "동기화 방의 고유 식별자",
"PLACEHOLDER_ROOM_ID": "방 ID 입력",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "참여하려는 방의 고유 ID",
"LABEL_PASSWORD": "비밀번호 (선택사항)",
"LABEL_PASSWORD_TOOLTIP": "방 액세스를 제한하기 위한 선택적 비밀번호",
"PLACEHOLDER_PASSWORD": "방 비밀번호 (선택사항)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "방 비밀번호 (없으면 비워둠)",
"BTN_JOIN_ROOM": "방 참여 / 만들기",
"BTN_JOIN_ROOM_TOOLTIP": "방에 연결",
"LABEL_PUBLIC_ROOMS": "공개 방",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "이 서버의 공개 방 목록",
"BTN_REFRESH": "Refreschi",
"BTN_REFRESH_TOOLTIP": "공개 방 목록 새로고침",
"PUBLIC_ROOMS_REFRESHING": "새로고침 중...",
"BTN_REFRESH_COOLDOWN": "{seconds}초 대기",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "방 목록 새로고침 대기 중입니다. {seconds}초 후에 다시 시도하세요.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "공개 방 새로고침 중. {seconds}초 후 새로고침 가능.",
"LABEL_ACTIVE_ROOM": "활성 방",
"LABEL_ACTIVE_ROOM_TOOLTIP": "현재 연결된 방",
"ACTIVE_ROOM_NONE": "없음",
"ACTIVE_SERVER_OFFICIAL": "공식 서버",
"LABEL_INVITE_LINK": "초대 링크",
"LABEL_INVITE_LINK_TOOLTIP": "친구들이 참여할 수 있도록 이 링크를 공유하세요",
"LABEL_PEERS_IN_ROOM": "방의 참여자",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "현재 이 방에 연결되어 있는 다른 사용자들",
"NO_PEERS_CONNECTED": "연결된 참여자가 없습니다",
"BTN_LEAVE_ROOM": "방 나가기",
"LABEL_SELECT_VIDEO": "비디오 선택",
"LABEL_SELECT_VIDEO_TOOLTIP": "동기화할 비디오가 포함된 브라우저 탭을 선택하세요",
"OPTION_SELECT_TAB": "-- 탭 선택 --",
"LABEL_REMOTE_CONTROL": "원격 제어",
"BTN_COPY_INVITE": "📋 초대 링크",
"BTN_COPY_INVITE_TOOLTIP": "초대 링크 복사",
"BTN_PLAY": "▶ 재생",
"BTN_PLAY_TOOLTIP": "모든 사람에게 재생 명령 전송",
"BTN_PAUSE": "⏸ 일시정지",
"BTN_PAUSE_TOOLTIP": "모든 사람에게 일시정지 명령 전송",
"BTN_SYNC": "⚡ 동기화",
"BTN_SYNC_TOOLTIP": "모든 사용자의 강제 동기화",
"OPTION_JUMP_TO_OTHERS": "다른 사람에게 이동",
"OPTION_JUMP_TO_ME": "나에게 이동",
"OPTION_JUMP_MODE_TOOLTIP": "동기화 대상 선택",
"LABEL_LAST_ACTIVITY": "최근 활동 상태",
"LABEL_LAST_ACTIVITY_TOOLTIP": "가장 최근의 재생, 일시정지, 탐색 명령을 표시합니다",
"NO_RECENT_COMMANDS": "최근 명령 없음",
"LOBBY_HEADER": "에피소드 로비",
"LOBBY_WAITING_FOR": "🎬 대기 중: \"{title}\"",
"LOBBY_WAITING_PEERS": "참여자 대기 중...",
"BTN_SKIP_PLAY": "건너뛰고 재생",
"BTN_SKIP_PLAY_TOOLTIP": "로비를 취소하고 재생합니다",
"LOBBY_CONNECT_FIRST": "먼저 방에 연결하세요",
"LOBBY_CONNECT_FIRST_DESC": "비디오를 동기화하려면 초대 링크를 통해 방에 참여하거나 새 방을 만들어야 합니다.",
"BTN_CREATE_ROOM_ALT": "새 방 만들기",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "새로운 무작위 방을 만들고 참여",
"LABEL_USERNAME": "사용자 이름",
"LABEL_USERNAME_TOOLTIP": "사용자 이름은 다른 사람들이 귀하를 식별하는 데 도움이 됩니다.",
"PLACEHOLDER_USERNAME": "익명의 코알라",
"LABEL_HIDE_CLUTTER": "복잡한 탭 숨기기",
"LABEL_HIDE_CLUTTER_TOOLTIP": "목록을 깔끔하게 유지하기 위해 비디오가 없는 탭과 관련 없는 도메인을 필터링합니다",
"LABEL_AUTO_SYNC_NEXT": "다음 에피소드 자동 동기화",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "에피소드가 변경되면 자동으로 일시정지하고 모든 참여자를 기다린 후 함께 동기화하여 시작합니다.",
"LABEL_AUTO_COPY_INVITE": "초대 링크 자동 복사",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "새 방을 만들 때 초대 링크를 클립보드에 자동으로 복사합니다.",
"LABEL_NOTIFICATIONS": "브라우저 알림",
"LABEL_NOTIFICATIONS_TOOLTIP": "누군가 참여/퇴장하거나 재생/일시정지할 때 시스템 알림을 표시합니다.",
"LABEL_LANGUAGE": "앱 언어",
"LABEL_LANGUAGE_TOOLTIP": "선호하는 확장 프로그램 언어를 선택하세요",
"LABEL_TROUBLESHOOTING": "문제 해결",
"LABEL_TROUBLESHOOTING_TOOLTIP": "연결 문제 해결을 위한 도구",
"BTN_REGEN_ID": "피어 ID 재생성",
"BTN_REGEN_ID_TOOLTIP": "내부 ID를 재생성하고 다시 연결합니다",
"REGEN_ID_DESC": "\"중복된 ID\" 오류가 발생할 경우에 사용하세요.",
"REGEN_ID_OTHER_ISSUE": "다른 문제? GitHub Issue 열기",
"LABEL_CONN_STATUS": "연결 상태",
"LABEL_CONN_STATUS_TOOLTIP": "현재 WebSocket 연결 상태",
"CONN_STATUS_DISCONNECTED": "연결 끊김",
"BTN_RETRY": "재시도",
"BTN_RETRY_TOOLTIP": "서버에 재연결 시도",
"BTN_COPY_LOGS": "로그 복사",
"BTN_COPY_LOGS_TOOLTIP": "공유를 위해 로그를 클립보드에 복사",
"LABEL_VIDEO_DEBUG": "비디오 디버그 정보",
"LABEL_VIDEO_DEBUG_TOOLTIP": "현재 선택된 비디오 요소에 대한 기술적 세부 정보",
"VIDEO_DEBUG_EMPTY": "선택된 탭이 없거나 비디오가 감지되지 않았습니다.",
"LABEL_HISTORY": "전체 작업 내역",
"LABEL_HISTORY_TOOLTIP": "방 내 모든 동기화 명령의 시간순 로그",
"HISTORY_EMPTY": "활동 없음",
"LABEL_LOGS": "로그 (최근 50개)",
"LABEL_LOGS_TOOLTIP": "디버깅용 기술 연결 로그",
"BTN_CLEAR": "지우기",
"BTN_CLEAR_TOOLTIP": "로그 출력 지우기",
"LABEL_GITHUB": "GitHub 저장소",
"BTN_ONBOARDING_SKIP": "건너뛰기",
"BTN_ONBOARDING_SKIP_TOOLTIP": "튜토리얼 건너뛰기",
"BTN_ONBOARDING_NEXT": "다음",
"BTN_ONBOARDING_NEXT_TOOLTIP": "다음 단계로 이동",
"ONBOARDING_1_TITLE": "KoalaSync에 오신 것을 환영합니다!",
"ONBOARDING_1_TEXT": "어디에 있든 완벽하게 동기화된 비디오를 함께 시청하세요. 간단히 둘러볼까요!",
"ONBOARDING_2_TITLE": "1. 방 만들기",
"ONBOARDING_2_TEXT": "여기서 시작하세요. 방을 만들고 친구들에게 초대 링크를 공유하세요.",
"ONBOARDING_3_TITLE": "2. 비디오 선택",
"ONBOARDING_3_TEXT": "동기화할 비디오를 선택하려면 여기로 이동하세요. 재생, 일시정지, 탐색이 모든 참여자에게 동기화됩니다.",
"ONBOARDING_4_TITLE": "3. 개인화",
"ONBOARDING_4_TEXT": "친구들이 나를 알아볼 수 있도록 재미있는 사용자 이름을 골라보세요.",
"ONBOARDING_5_TITLE": "모든 준비가 끝났습니다!",
"ONBOARDING_5_TEXT": "팝콘을 준비할 시간입니다. 함께 즐겁게 시청하세요!",
"ERR_CONN_TIMEOUT": "연결 시간이 초과되었습니다. 다시 시도해 주세요.",
"ERR_INVALID_SERVER_URL": "올바르지 않은 서버 URL 형식입니다.",
"ERR_IDENTITY_NOT_LOADED": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.",
"ERR_NO_PEERS_TIME": "시간을 알고 있는 다른 피어가 없습니다. '나에게 이동'으로 전환하세요.",
"ERR_NO_VIDEO_TAB": "비디오 탭에 연결할 수 없습니다.",
"ERR_SELECT_VIDEO": "먼저 비디오를 선택하세요!",
"TOAST_INVITE_COPIED": "초대 링크가 복사되었습니다!",
"TOAST_COPY_FAILED": "클립보드 복사에 실패했습니다",
"TOAST_LOBBY_SKIPPED": "에피소드 로비를 건너뛰었습니다.",
"TOAST_LOBBY_SKIP_FAILED": "로비 건너뛰기에 실패했습니다.",
"TOAST_LOGS_COPIED": "복사됨!",
"TOAST_PEER_JOINED": "{name} 님이 방에 참여했습니다",
"TOAST_PEER_LEFT": "{name} 님이 방에서 나갔습니다",
"TOAST_PEER_ACTION": "{name} 님이 {action}",
"STATUS_CONNECTED": "연결됨",
"STATUS_RECONNECTING": "재연결 중...",
"STATUS_CONNECTING": "연kel 중...",
"STATUS_FAILED": "실패",
"STATUS_DISCONNECTED": "연결 끊김",
"BTN_STATE_JOINING": "🚀 참여 중...",
"BTN_STATE_RECONNECTING": "🔄 재연결 중...",
"BTN_STATE_PLAYING": "▶ 재생 중...",
"BTN_STATE_PAUSING": "⏸ 일시정지 중...",
"BTN_STATE_SYNCING_GROUP": "그룹에 동기화 중 ({time})...",
"BTN_STATE_SYNCING": "동기화 중...",
"BTN_STATE_SYNCED": "✅ 동기화됨!",
"NOTIF_PLAY": "재생을 시작했습니다",
"NOTIF_PAUSE": "재생을 일시정지했습니다",
"NOTIF_SEEK": "비디오를 탐색했습니다",
"NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다",
"NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다",
"DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.",
"DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.",
"EMPTY_PEERS_TITLE": "참여자 없음",
"EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요",
"EMPTY_HISTORY_TITLE": "활동 없음",
"EMPTY_HISTORY_HINT": "기록을 보려면 재생, 일시정지 또는 탐색을 해보세요",
"EMPTY_LOGS_TITLE": "로그 없음",
"EMPTY_LOGS_HINT": "연결 이벤트가 여기에 표시됩니다",
"EMPTY_ROOMS_TITLE": "활성 방 없음",
"EMPTY_ROOMS_HINT": "방을 만들거나 새로고침하여 공개 방을 찾으세요",
"LABEL_YOU": "나",
"ONBOARDING_DONE": "완료!",
"LABEL_LOBBY_PEER_READY": "준비",
"LABEL_LOBBY_PEER_LOADING": "로딩 중...",
"LABEL_PASSWORD_PROTECTED": "비밀번호 보호됨",
"LABEL_PEERS_COUNT": "{count}명",
"LABEL_CUSTOM_SERVER": "사용자 정의 서버",
"BTN_STATE_CREATING": "🚀 방 만드는 중...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — 에피소드 동기화 실패",
"NOTIF_LOBBY_CANCEL_MSG": "자동 동기화 취소됨: {reason}. 수동 동기화가 필요할 수 있습니다.",
"LOBBY_CANCEL_TIMEOUT": "시간 초과",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "시간 초과 (복구됨)",
"LOBBY_CANCEL_PEERS_LEFT": "다른 모든 피어가 나갔습니다",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "시간 초과 — 모든 피어가 에피소드를 로드하지 못했습니다",
"LOBBY_CANCEL_USER": "사용자에 의해 취소됨",
"NOTIF_ERROR_TITLE": "KoalaSync 오류",
"FOOTER_SUPPORT": "☕ 커피 한 잔 사주세요",
"FOOTER_REVIEW": "★ 평가하기",
"FOOTER_SUPPORT_PROMPT": "KoalaSync가 마음에 드세요? 리뷰를 남겨주세요!",
"LABEL_AUDIO_PROCESSING": "오디오 처리",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "비디오 재생에 압축과 같은 오디오 효과를 적용합니다",
"AUDIO_OPEN_SETTINGS": "열기",
"NEW_FEATURE_AUDIO": "신규: 오디오 처리 — 컴프레서를 사용해보세요!",
"AUDIO_BACK": "← 뒤로",
"AUDIO_PAGE_TITLE": "오디오 설정",
"AUDIO_MASTER_TOGGLE": "오디오 처리",
"AUDIO_COMPRESSOR": "컴프레서",
"AUDIO_COMPRESSOR_ENABLE": "활성화",
"AUDIO_PRESET": "프리셋",
"AUDIO_PRESET_RECOMMENDED": "추천",
"AUDIO_PRESET_DYNAMIC_RANGE": "다이내믹 레인지",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "음성 강화",
"AUDIO_PRESET_SMOOTH": "부드럽게",
"AUDIO_PRESET_CUSTOM": "사용자 정의",
"AUDIO_PARAM_THRESHOLD": "임계값",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "이퀄라이저",
"AUDIO_COMING_SOON": "출시 예정"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "nl",
"HTML_CLASS": "lang-nl",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Kamer",
"TAB_ROOM_TOOLTIP": "Kamerinstellingen en verbinding",
"TAB_SYNC": "Sync",
"TAB_SYNC_TOOLTIP": "Videobeheer en externe acties",
"TAB_SETTINGS": "Instellingen",
"TAB_SETTINGS_TOOLTIP": "Extensievoorkeuren",
"TAB_STATUS": "Status",
"TAB_STATUS_TOOLTIP": "Geavanceerde diagnostische gegevens & logs",
"BTN_CREATE_ROOM": "+ Nieuwe kamer maken",
"MANUAL_CONNECT_HEADER": "Handmatig verbinden / Geavanceerd",
"LABEL_SERVER": "Server",
"BTN_SERVER_OFFICIAL": "Officieel",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Gebruik de officiële betrouwbare server",
"BTN_SERVER_CUSTOM": "Aangepast",
"BTN_SERVER_CUSTOM_TOOLTIP": "Verbind met uw eigen gehoste server",
"PLACEHOLDER_SERVER_URL": "wss://uw-server:3000",
"LABEL_ROOM_ID": "Kamer-ID",
"LABEL_ROOM_ID_TOOLTIP": "De unieke identificatie voor uw synchronisatiekamer",
"PLACEHOLDER_ROOM_ID": "Voer kamer-ID in",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Het unieke ID van de kamer waar u aan wilt deelnemen",
"LABEL_PASSWORD": "Wachtwoord (optioneel)",
"LABEL_PASSWORD_TOOLTIP": "Optioneel wachtwoord om toegang tot de kamer te beperken",
"PLACEHOLDER_PASSWORD": "Kamerwachtwoord (optioneel)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Wachtwoord voor de kamer (leeg laten indien geen)",
"BTN_JOIN_ROOM": "Deelnemen / Kamer maken",
"BTN_JOIN_ROOM_TOOLTIP": "Verbinden met de kamer",
"LABEL_PUBLIC_ROOMS": "Openbare kamers",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lijst met openbaar beschikbare kamers op deze server",
"BTN_REFRESH": "VERNIEUWEN",
"BTN_REFRESH_TOOLTIP": "Vernieuw de lijst met openbare kamers",
"PUBLIC_ROOMS_REFRESHING": "Vernieuwen...",
"BTN_REFRESH_COOLDOWN": "WACHT {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Vernieuwen van kamerlijst is aan het afkoelen. Probeer opnieuw in {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Openbare kamers vernieuwen. Volgende verversing beschikbaar in {seconds}s.",
"LABEL_ACTIVE_ROOM": "Actieve kamer",
"LABEL_ACTIVE_ROOM_TOOLTIP": "De kamer waar u momenteel mee bent verbonden",
"ACTIVE_ROOM_NONE": "GEEN",
"ACTIVE_SERVER_OFFICIAL": "Officiële server",
"LABEL_INVITE_LINK": "Uitnodigingslink",
"LABEL_INVITE_LINK_TOOLTIP": "Deel deze link met vrienden zodat ze kunnen deelnemen",
"LABEL_PEERS_IN_ROOM": "Deelnemers in kamer",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere gebruikers die momenteel zijn verbonden met deze kamer",
"NO_PEERS_CONNECTED": "Geen deelnemers verbonden",
"BTN_LEAVE_ROOM": "Kamer verlaten",
"LABEL_SELECT_VIDEO": "Selecteer video",
"LABEL_SELECT_VIDEO_TOOLTIP": "Kies het browsertabblad met de video die u wilt synchroniseren",
"OPTION_SELECT_TAB": "-- Selecteer een tabblad --",
"LABEL_REMOTE_CONTROL": "Afstandsbediening",
"BTN_COPY_INVITE": "📋 Uitnodigingslink",
"BTN_COPY_INVITE_TOOLTIP": "Uitnodigingslink kopiëren",
"BTN_PLAY": "▶ Afspelen",
"BTN_PLAY_TOOLTIP": "Stuur een afspeelopdracht naar iedereen",
"BTN_PAUSE": "⏸ Pauzeren",
"BTN_PAUSE_TOOLTIP": "Stuur een pauzeeropdracht naar iedereen",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Dwing alle gebruikers om te synchroniseren",
"OPTION_JUMP_TO_OTHERS": "Spring naar anderen",
"OPTION_JUMP_TO_ME": "Spring naar mij",
"OPTION_JUMP_MODE_TOOLTIP": "Kies synchronisatiedoel",
"LABEL_LAST_ACTIVITY": "Laatste activiteitsstatus",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Toont de meest recente afspeel-, pauzeer- of zoekopdracht",
"NO_RECENT_COMMANDS": "Geen recente commando's",
"LOBBY_HEADER": "EPISODE LOBBY",
"LOBBY_WAITING_FOR": "🎬 Wachten op: \"{title}\"",
"LOBBY_WAITING_PEERS": "Wachten op deelnemers...",
"BTN_SKIP_PLAY": "Overslaan & toch afspelen",
"BTN_SKIP_PLAY_TOOLTIP": "Lobby annuleren en toch afspelen",
"LOBBY_CONNECT_FIRST": "Verbind eerst met een kamer",
"LOBBY_CONNECT_FIRST_DESC": "U moet deelnemen aan een kamer via een uitnodigingslink of een nieuwe maken om video's te synchroniseren.",
"BTN_CREATE_ROOM_ALT": "Nieuwe kamer maken",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Maak een nieuwe willekeurige kamer en neem deel",
"LABEL_USERNAME": "Uw gebruikersnaam",
"LABEL_USERNAME_TOOLTIP": "Gebruikersnaam helpt anderen u te identificeren.",
"PLACEHOLDER_USERNAME": "Anonieme Koala",
"LABEL_HIDE_CLUTTER": "Overbodige tabbladen verbergen",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtert tabbladen zonder video's en niet-gerelateerde domeinen om de lijst schoon te houden",
"LABEL_AUTO_SYNC_NEXT": "Volgende aflevering automatisch syncen",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pauzeert automatisch en wacht op alle deelnemers wanneer een aflevering verandert, en start dan gezamenlijk gesynchroniseerd.",
"LABEL_AUTO_COPY_INVITE": "Uitnodigingslink automatisch kopiëren",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Kopieert de uitnodigingslink automatisch naar uw klembord bij het maken van een nieuwe kamer.",
"LABEL_NOTIFICATIONS": "Browsernotificaties",
"LABEL_NOTIFICATIONS_TOOLTIP": "Toont systeemnotificaties wanneer iemand deelneemt/verlaat of afspeelt/pauzeert.",
"LABEL_LANGUAGE": "Taal app",
"LABEL_LANGUAGE_TOOLTIP": "Kies uw favoriete taal voor de extensie",
"LABEL_TROUBLESHOOTING": "Problemen oplossen",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Hulpmiddelen voor het oplossen van verbindingsproblemen",
"BTN_REGEN_ID": "Peer-ID opnieuw genereren",
"BTN_REGEN_ID_TOOLTIP": "Genereer uw interne ID opnieuw en maak opnieuw verbinding",
"REGEN_ID_DESC": "Gebruik dit als u fouten ziet over 'Duplicate Identity'.",
"REGEN_ID_OTHER_ISSUE": "Ander probleem? Open een GitHub Issue",
"LABEL_CONN_STATUS": "Verbindingsstatus",
"LABEL_CONN_STATUS_TOOLTIP": "Huidige WebSocket-verbindingsstatus",
"CONN_STATUS_DISCONNECTED": "Verbinding verbroken",
"BTN_RETRY": "OPNIEUW PROBEREN",
"BTN_RETRY_TOOLTIP": "Proberen opnieuw verbinding te maken met de server",
"BTN_COPY_LOGS": "Logs kopiëren",
"BTN_COPY_LOGS_TOOLTIP": "Kopieer logboeken naar het klembord om te delen",
"LABEL_VIDEO_DEBUG": "Video-debuginfo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Technische details over het momenteel geselecteerde video-element",
"VIDEO_DEBUG_EMPTY": "Geen tabblad geselecteerd of video gedetecteerd.",
"LABEL_HISTORY": "Volledige actiegeschiedenis",
"LABEL_HISTORY_TOOLTIP": "Chronologisch logboek van alle synchronisatieopdrachten in de kamer",
"HISTORY_EMPTY": "Nog geen activiteit",
"LABEL_LOGS": "Logs (Laatste 50)",
"LABEL_LOGS_TOOLTIP": "Technische verbindingslogboeken voor debuggen",
"BTN_CLEAR": "WISSEN",
"BTN_CLEAR_TOOLTIP": "Logboekuitvoer wissen",
"LABEL_GITHUB": "GitHub-repository",
"BTN_ONBOARDING_SKIP": "Overslaan",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Sla de tutorial over",
"BTN_ONBOARDING_NEXT": "Volgende",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ga naar de volgende stap",
"ONBOARDING_1_TITLE": "Welkom bij KoalaSync!",
"ONBOARDING_1_TEXT": "Bekijk video's samen in perfecte synchronisatie — waar u ook bent. Laten we een snelle rondleiding nemen!",
"ONBOARDING_2_TITLE": "1. Kamer maken",
"ONBOARDING_2_TEXT": "Begin hier. Maak een kamer en deel de uitnodigingslink met uw vrienden.",
"ONBOARDING_3_TITLE": "2. Selecteer video",
"ONBOARDING_3_TEXT": "Navigeer hiernaartoe om de video te selecteren die u wilt synchroniseren. Spelen, pauzeren en zoeken — iedereen blijft synchroon.",
"ONBOARDING_4_TITLE": "3. Personaliseren",
"ONBOARDING_4_TEXT": "Kies een leuke gebruikersnaam zodat uw vrienden weten wie u bent.",
"ONBOARDING_5_TITLE": "U bent er helemaal klaar voor!",
"ONBOARDING_5_TEXT": "Tijd voor popcorn. Veel plezier met samen kijken!",
"ERR_CONN_TIMEOUT": "Verbinding time-out. Probeer het opnieuw.",
"ERR_INVALID_SERVER_URL": "Ongeldig server URL-formaat.",
"ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.",
"ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.",
"ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.",
"ERR_SELECT_VIDEO": "Selecteer eerst een video!",
"TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!",
"TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt",
"TOAST_LOBBY_SKIPPED": "Episode Lobby overgeslagen.",
"TOAST_LOBBY_SKIP_FAILED": "Lobby overslaan mislukt.",
"TOAST_LOGS_COPIED": "Gekopieerd!",
"TOAST_PEER_JOINED": "{name} neemt deel aan de kamer",
"TOAST_PEER_LEFT": "{name} heeft de kamer verlaten",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Verbonden",
"STATUS_RECONNECTING": "Opnieuw verbinden...",
"STATUS_CONNECTING": "Verbinden...",
"STATUS_FAILED": "Mislukt",
"STATUS_DISCONNECTED": "Verbinding verbroken",
"BTN_STATE_JOINING": "🚀 Deelnemen...",
"BTN_STATE_RECONNECTING": "🔄 Opnieuw verbinden...",
"BTN_STATE_PLAYING": "▶ Afspelen...",
"BTN_STATE_PAUSING": "⏸ Pauzeren...",
"BTN_STATE_SYNCING_GROUP": "Synchroniseren met groep ({time})...",
"BTN_STATE_SYNCING": "Synchroniseren...",
"BTN_STATE_SYNCED": "✅ Gesynchroniseerd!",
"NOTIF_PLAY": "is begonnen met afspelen",
"NOTIF_PAUSE": "heeft afspelen gepauzeerd",
"NOTIF_SEEK": "heeft de video gespoeld",
"NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart",
"NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd",
"DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.",
"DEBUG_COMM_FAIL": "Kon niet cmmuniceren met de videotab.",
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
"EMPTY_HISTORY_TITLE": "Nog geen activiteit",
"EMPTY_HISTORY_HINT": "Speel af, pauzeer of spoel om geschiedenis te zien",
"EMPTY_LOGS_TITLE": "Geen logs",
"EMPTY_LOGS_HINT": "Verbindingsgebeurtenissen verschijnen hier",
"EMPTY_ROOMS_TITLE": "Geen actieve kamers",
"EMPTY_ROOMS_HINT": "Maak een kamer of vernieuw om openbare te vinden",
"LABEL_YOU": "Jij",
"ONBOARDING_DONE": "Klaar!",
"LABEL_LOBBY_PEER_READY": "Gereed",
"LABEL_LOBBY_PEER_LOADING": "Laden...",
"LABEL_PASSWORD_PROTECTED": "Wachtwoord beveiligd",
"LABEL_PEERS_COUNT": "{count} deelnemers",
"LABEL_CUSTOM_SERVER": "Aangepaste server",
"BTN_STATE_CREATING": "🚀 Kamer maken...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Episode Sync mislukt",
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync geannuleerd: {reason}. Mogelijk moet u handmatig synchroniseren.",
"LOBBY_CANCEL_TIMEOUT": "Time-out",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Time-out (hersteld)",
"LOBBY_CANCEL_PEERS_LEFT": "Alle andere deelnemers zijn vertrokken",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Time-out — niet alle deelnemers hebben de aflevering geladen",
"LOBBY_CANCEL_USER": "Geannuleerd door gebruiker",
"NOTIF_ERROR_TITLE": "KoalaSync-fout",
"FOOTER_SUPPORT": "☕ Trakteer op een koffie",
"FOOTER_REVIEW": "★ Beoordelen",
"FOOTER_SUPPORT_PROMPT": "Vind je KoalaSync leuk? Laat een review achter!",
"LABEL_AUDIO_PROCESSING": "Audioverwerking",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Past audio-effecten zoals compressie toe op video-afspelen",
"AUDIO_OPEN_SETTINGS": "Openen",
"NEW_FEATURE_AUDIO": "Nieuw: Audioverwerking — probeer de compressor!",
"AUDIO_BACK": "← Terug",
"AUDIO_PAGE_TITLE": "Audio-instellingen",
"AUDIO_MASTER_TOGGLE": "Audioverwerking",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ingeschakeld",
"AUDIO_PRESET": "Voorinstelling",
"AUDIO_PRESET_RECOMMENDED": "Aanbevolen",
"AUDIO_PRESET_DYNAMIC_RANGE": "Dynamisch bereik",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Stemverbetering",
"AUDIO_PRESET_SMOOTH": "Zacht",
"AUDIO_PRESET_CUSTOM": "Aangepast",
"AUDIO_PARAM_THRESHOLD": "Drempel",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Binnenkort beschikbaar"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "pl",
"HTML_CLASS": "lang-pl",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Pokój",
"TAB_ROOM_TOOLTIP": "Ustawienia pokoju i połączenie",
"TAB_SYNC": "Sync",
"TAB_SYNC_TOOLTIP": "Sterowanie synchronizacją wideo i zdalne akcje",
"TAB_SETTINGS": "Ustawienia",
"TAB_SETTINGS_TOOLTIP": "Preferencje rozszerzenia",
"TAB_STATUS": "Status",
"TAB_STATUS_TOOLTIP": "Zaawansowana diagnostyka i logi",
"BTN_CREATE_ROOM": "+ Utwórz nowy pokój",
"MANUAL_CONNECT_HEADER": "Połączenie ręczne / Zaawansowane",
"LABEL_SERVER": "Serwer",
"BTN_SERVER_OFFICIAL": "Oficjalny",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Użyj oficjalnego, niezawodnego serwera",
"BTN_SERVER_CUSTOM": "Własny",
"BTN_SERVER_CUSTOM_TOOLTIP": "Połącz się z własnym serwerem",
"PLACEHOLDER_SERVER_URL": "wss://twoj-serwer:3000",
"LABEL_ROOM_ID": "ID pokoju",
"LABEL_ROOM_ID_TOOLTIP": "Unikalny identyfikator pokoju synchronizacji",
"PLACEHOLDER_ROOM_ID": "Wpisz ID pokoju",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Unikalny identyfikator pokoju, do którego chcesz dołączyć",
"LABEL_PASSWORD": "Hasło (opcjonalnie)",
"LABEL_PASSWORD_TOOLTIP": "Opcjonalne hasło ograniczające dostęp do pokoju",
"PLACEHOLDER_PASSWORD": "Hasło pokoju (opcjonalnie)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Hasło do pokoju (pozostaw puste, jeśli brak)",
"BTN_JOIN_ROOM": "Dołącz / Utwórz pokój",
"BTN_JOIN_ROOM_TOOLTIP": "Połącz z pokojem",
"LABEL_PUBLIC_ROOMS": "Pokoje publiczne",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista publicznie dostępnych pokoi na tym serwerze",
"BTN_REFRESH": "ODŚWIEŻ",
"BTN_REFRESH_TOOLTIP": "Odśwież listę pokoi publicznych",
"PUBLIC_ROOMS_REFRESHING": "Odświeżanie...",
"BTN_REFRESH_COOLDOWN": "CZEKAJ {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Odświeżanie listy pokoi stygnie. Spróbuj ponownie za {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Odświeżanie pokoi publicznych. Następne odświeżenie za {seconds}s.",
"LABEL_ACTIVE_ROOM": "Aktywny pokój",
"LABEL_ACTIVE_ROOM_TOOLTIP": "Pokój, z którym jesteś aktualnie połączony",
"ACTIVE_ROOM_NONE": "BRAK",
"ACTIVE_SERVER_OFFICIAL": "Oficjalny serwer",
"LABEL_INVITE_LINK": "Link do zaproszenia",
"LABEL_INVITE_LINK_TOOLTIP": "Udostępnij ten link znajomym, aby mogli dołączyć",
"LABEL_PEERS_IN_ROOM": "Uczestnicy w pokoju",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Inni użytkownicy aktualnie połączeni z tym pokojem",
"NO_PEERS_CONNECTED": "Brak połączonych uczestników",
"BTN_LEAVE_ROOM": "Opuść pokój",
"LABEL_SELECT_VIDEO": "Wybierz wideo",
"LABEL_SELECT_VIDEO_TOOLTIP": "Wybierz kartę przeglądarki zawierającą wideo do synchronizacji",
"OPTION_SELECT_TAB": "-- Wybierz kartę --",
"LABEL_REMOTE_CONTROL": "Zdalne sterowanie",
"BTN_COPY_INVITE": "📋 Link do zaproszenia",
"BTN_COPY_INVITE_TOOLTIP": "Kopiuj link zaproszenia",
"BTN_PLAY": "▶ Odtwórz",
"BTN_PLAY_TOOLTIP": "Wyślij polecenie odtwarzania do wszystkich",
"BTN_PAUSE": "⏸ Pauza",
"BTN_PAUSE_TOOLTIP": "Wyślij polecenie pauzy do wszystkich",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Wymuś synchronizację wszystkich użytkowników",
"OPTION_JUMP_TO_OTHERS": "Skocz do innych",
"OPTION_JUMP_TO_ME": "Skocz do mnie",
"OPTION_JUMP_MODE_TOOLTIP": "Wybierz cel synchronizacji",
"LABEL_LAST_ACTIVITY": "Status ostatniej aktywności",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Pokazuje najnowsze polecenie odtwarzania, pauzy lub przewijania",
"NO_RECENT_COMMANDS": "Brak ostatnich poleceń",
"LOBBY_HEADER": "LOBBY ODCINKA",
"LOBBY_WAITING_FOR": "🎬 Czekam na: \"{title}\"",
"LOBBY_WAITING_PEERS": "Oczekiwanie na uczestników...",
"BTN_SKIP_PLAY": "Pomiń i odtwórz mimo to",
"BTN_SKIP_PLAY_TOOLTIP": "Anuluj lobby i odtwórz mimo to",
"LOBBY_CONNECT_FIRST": "Najpierw połącz się z pokojem",
"LOBBY_CONNECT_FIRST_DESC": "Musisz dołączyć do pokoju przez link zaproszenia lub utworzyć nowy, aby synchronizować wideo.",
"BTN_CREATE_ROOM_ALT": "Utwórz nowy pokój",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Utwórz nowy losowy pokój i dołącz",
"LABEL_USERNAME": "Twoja nazwa użytkownika",
"LABEL_USERNAME_TOOLTIP": "Nazwa użytkownika pomaga innym Cię zidentyfikować.",
"PLACEHOLDER_USERNAME": "Anonimowy Koala",
"LABEL_HIDE_CLUTTER": "Ukryj zbędne karty",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtruje karty bez wideo i niepowiązane domeny, aby zachować porządek",
"LABEL_AUTO_SYNC_NEXT": "Auto-sync następnego odcinka",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Automatycznie wstrzymuje i czeka na wszystkich uczestników po zmianie odcinka, a następnie uruchamia się wspólnie.",
"LABEL_AUTO_COPY_INVITE": "Auto-kopiowanie zaproszenia",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Automatycznie kopiuje link zaproszenia do schowka po utworzeniu nowego pokoju.",
"LABEL_NOTIFICATIONS": "Powiadomienia przeglądarki",
"LABEL_NOTIFICATIONS_TOOLTIP": "Pokazuje natywne powiadomienia systemowe, gdy ktoś dołącza/odchodzi lub odtwarza/wstrzymuje.",
"LABEL_LANGUAGE": "Język aplikacji",
"LABEL_LANGUAGE_TOOLTIP": "Wybierz preferowany język rozszerzenia",
"LABEL_TROUBLESHOOTING": "Rozwiązywanie problemów",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Narzędzia do rozwiązywania problemów z połączeniem",
"BTN_REGEN_ID": "Wygeneruj ponownie ID",
"BTN_REGEN_ID_TOOLTIP": "Wygeneruj ponownie swój identyfikator i połącz się ponownie",
"REGEN_ID_DESC": "Użyj tego, jeśli widzisz błędy o zduplikowanej tożsamości.",
"REGEN_ID_OTHER_ISSUE": "Inny problem? Otwórz Issue na GitHub",
"LABEL_CONN_STATUS": "Status połączenia",
"LABEL_CONN_STATUS_TOOLTIP": "Bieżący stan połączenia WebSocket",
"CONN_STATUS_DISCONNECTED": "Rozłączono",
"BTN_RETRY": "PONÓW",
"BTN_RETRY_TOOLTIP": "Próba ponownego połączenia z serwerem",
"BTN_COPY_LOGS": "Kopiuj logi",
"BTN_COPY_LOGS_TOOLTIP": "Kopiuj logi do schowka, aby je udostępnić",
"LABEL_VIDEO_DEBUG": "Info debugowania wideo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Szczegóły techniczne wybranego elementu wideo",
"VIDEO_DEBUG_EMPTY": "Nie wybrano karty ani nie wykryto wideo.",
"LABEL_HISTORY": "Pełna historia akcji",
"LABEL_HISTORY_TOOLTIP": "Chronologiczny rejestr wszystkich poleceń synchronizacji w pokoju",
"HISTORY_EMPTY": "Brak aktywności",
"LABEL_LOGS": "Logi (ostatnie 50)",
"LABEL_LOGS_TOOLTIP": "Logi połączenia technicznego do debugowania",
"BTN_CLEAR": "WYCZYŚĆ",
"BTN_CLEAR_TOOLTIP": "Wyczyść dane wyjściowe logów",
"LABEL_GITHUB": "Repozytorium GitHub",
"BTN_ONBOARDING_SKIP": "Pomiń",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Pomiń samouczek",
"BTN_ONBOARDING_NEXT": "Dalej",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Przejdź do następnego kroku",
"ONBOARDING_1_TITLE": "Witaj w KoalaSync!",
"ONBOARDING_1_TEXT": "Oglądaj filmy w idealnej synchronizacji — bez względu na to, gdzie jesteś. Zróbmy szybką wycieczkę!",
"ONBOARDING_2_TITLE": "1. Utwórz pokój",
"ONBOARDING_2_TEXT": "Zacznij tutaj. Utwórz pokój i udostępnij link zaproszenia znajomym.",
"ONBOARDING_3_TITLE": "2. Wybierz wideo",
"ONBOARDING_3_TEXT": "Przejdź tutaj, aby wybrać wideo, które chcesz synchronizować. Odtwarzaj, wstrzymuj i przewijaj — każdy pozostaje zsynchronizowany.",
"ONBOARDING_4_TITLE": "3. Personalizacja",
"ONBOARDING_4_TEXT": "Wybierz fajną nazwę użytkownika, aby znajomi wiedzieli, kim jesteś.",
"ONBOARDING_5_TITLE": "Wszystko gotowe!",
"ONBOARDING_5_TEXT": "Czas na popcorn. Miłego wspólnego oglądania!",
"ERR_CONN_TIMEOUT": "Przekroczono limit czasu połączenia. Spróbuj ponownie.",
"ERR_INVALID_SERVER_URL": "Nieprawidłowy format URL serwera.",
"ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.",
"ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.",
"ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.",
"ERR_SELECT_VIDEO": "Najpierw wybierz wideo!",
"TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!",
"TOAST_COPY_FAILED": "Nie udało się skopiować do schowka",
"TOAST_LOBBY_SKIPPED": "Pominięto lobby odcinka.",
"TOAST_LOBBY_SKIP_FAILED": "Nie udało się pominąć lobby.",
"TOAST_LOGS_COPIED": "Skopiowano!",
"TOAST_PEER_JOINED": "{name} dołączył do pokoju",
"TOAST_PEER_LEFT": "{name} opuścił pokój",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Połączono",
"STATUS_RECONNECTING": "Ponowne łączenie...",
"STATUS_CONNECTING": "Łączenie...",
"STATUS_FAILED": "Nieudane",
"STATUS_DISCONNECTED": "Rozłączono",
"BTN_STATE_JOINING": "🚀 Dołączanie...",
"BTN_STATE_RECONNECTING": "🔄 Ponowne łączenie...",
"BTN_STATE_PLAYING": "▶ Odtwarzanie...",
"BTN_STATE_PAUSING": "⏸ Wstrzymywanie...",
"BTN_STATE_SYNCING_GROUP": "Synchronizacja do grupy ({time})...",
"BTN_STATE_SYNCING": "Synchronizacja...",
"BTN_STATE_SYNCED": "✅ Zsynchronizowano!",
"NOTIF_PLAY": "rozpoczął odtwarzanie",
"NOTIF_PAUSE": "wstrzymał odtwarzanie",
"NOTIF_SEEK": "przewinął wideo",
"NOTIF_FORCE_PREPARE": "wymusił synchronizację",
"NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich",
"DEBUG_NO_TAB": "Nie wybrano karty docelowej.",
"DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.",
"EMPTY_PEERS_TITLE": "Brak uczestników",
"EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć",
"EMPTY_HISTORY_TITLE": "Brak aktywności",
"EMPTY_HISTORY_HINT": "Odtwórz, wstrzymaj lub przewiń, aby zobaczyć historię",
"EMPTY_LOGS_TITLE": "Brak logów",
"EMPTY_LOGS_HINT": "Zdarzenia połączenia pojawią się tutaj",
"EMPTY_ROOMS_TITLE": "Brak aktywnych pokoi",
"EMPTY_ROOMS_HINT": "Utwórz pokój lub odśwież, aby znaleźć publiczne",
"LABEL_YOU": "Ty",
"ONBOARDING_DONE": "Gotowe!",
"LABEL_LOBBY_PEER_READY": "Gotowy",
"LABEL_LOBBY_PEER_LOADING": "Ładowanie...",
"LABEL_PASSWORD_PROTECTED": "Chronione hasłem",
"LABEL_PEERS_COUNT": "{count} uczestników",
"LABEL_CUSTOM_SERVER": "Własny serwer",
"BTN_STATE_CREATING": "🚀 Tworzenie pokoju...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Synchronizacja odcinka nieudana",
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync anulowane: {reason}. Może być konieczna ręczna synchronizacja.",
"LOBBY_CANCEL_TIMEOUT": "Limit czasu",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Limit czasu (przywrócono)",
"LOBBY_CANCEL_PEERS_LEFT": "Wszyscy inni uczestnicy wyszli",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Limit czasu — nie wszyscy uczestnicy załadowali odcinek",
"LOBBY_CANCEL_USER": "Anulowane przez użytkownika",
"NOTIF_ERROR_TITLE": "Błąd KoalaSync",
"FOOTER_SUPPORT": "☕ Postaw kawę",
"FOOTER_REVIEW": "★ Oceń",
"FOOTER_SUPPORT_PROMPT": "Podoba Ci się KoalaSync? Zostaw recenzję!",
"LABEL_AUDIO_PROCESSING": "Przetwarzanie dźwięku",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Stosuje efekty dźwiękowe, takie jak kompresja, do odtwarzania wideo",
"AUDIO_OPEN_SETTINGS": "Otwórz",
"NEW_FEATURE_AUDIO": "Nowość: Przetwarzanie dźwięku — wypróbuj kompresor!",
"AUDIO_BACK": "← Wstecz",
"AUDIO_PAGE_TITLE": "Ustawienia dźwięku",
"AUDIO_MASTER_TOGGLE": "Przetwarzanie dźwięku",
"AUDIO_COMPRESSOR": "Kompresor",
"AUDIO_COMPRESSOR_ENABLE": "Włączony",
"AUDIO_PRESET": "Preset",
"AUDIO_PRESET_RECOMMENDED": "Zalecany",
"AUDIO_PRESET_DYNAMIC_RANGE": "Zakres dynamiki",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Wzmocnienie głosu",
"AUDIO_PRESET_SMOOTH": "Płynny",
"AUDIO_PRESET_CUSTOM": "Niestandardowy",
"AUDIO_PARAM_THRESHOLD": "Próg",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Korektor",
"AUDIO_COMING_SOON": "Wkrótce"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "pt-BR",
"HTML_CLASS": "lang-pt-br",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Sala",
"TAB_ROOM_TOOLTIP": "Configurações da sala e conexão",
"TAB_SYNC": "Sincro",
"TAB_SYNC_TOOLTIP": "Controles de sincronia de vídeo e ações",
"TAB_SETTINGS": "Opções",
"TAB_SETTINGS_TOOLTIP": "Preferências da extensão",
"TAB_STATUS": "Status",
"TAB_STATUS_TOOLTIP": "Diagnósticos avançados e logs",
"BTN_CREATE_ROOM": "+ Criar nova sala",
"MANUAL_CONNECT_HEADER": "Conexão manual / Avançado",
"LABEL_SERVER": "Servidor",
"BTN_SERVER_OFFICIAL": "Oficial",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar o servidor oficial confiável",
"BTN_SERVER_CUSTOM": "Perso",
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectar ao seu próprio servidor auto-hospedado",
"PLACEHOLDER_SERVER_URL": "wss://seu-servidor:3000",
"LABEL_ROOM_ID": "ID da sala",
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para sua sala de sincronização",
"PLACEHOLDER_ROOM_ID": "Digite o ID da sala",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala na qual você deseja entrar",
"LABEL_PASSWORD": "Senha (Opcional)",
"LABEL_PASSWORD_TOOLTIP": "Senha opcional para restringir o acesso à sala",
"PLACEHOLDER_PASSWORD": "Senha da sala (opcional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Senha para a sala (deixe em branco se não houver)",
"BTN_JOIN_ROOM": "Entrar / Criar sala",
"BTN_JOIN_ROOM_TOOLTIP": "Conectar à 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": "Atualizando...",
"BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A lista de salas está em espera. Tente novamente em {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Atualizando salas públicas. Próxima atualização em {seconds}s.",
"LABEL_ACTIVE_ROOM": "Sala ativa",
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual você está conectado no momento",
"ACTIVE_ROOM_NONE": "NENHUMA",
"ACTIVE_SERVER_OFFICIAL": "Servidor oficial",
"LABEL_INVITE_LINK": "Link de convite",
"LABEL_INVITE_LINK_TOOLTIP": "Compartilhe este link com amigos para que eles entrem",
"LABEL_PEERS_IN_ROOM": "Participantes na sala",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros usuários conectados atualmente a esta sala",
"NO_PEERS_CONNECTED": "Sem participantes conectados",
"BTN_LEAVE_ROOM": "Sair da sala",
"LABEL_SELECT_VIDEO": "Selecionar vídeo",
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolha a aba do navegador contendo o vídeo a ser sincronizado",
"OPTION_SELECT_TAB": "-- Selecione uma aba --",
"LABEL_REMOTE_CONTROL": "Controle remoto",
"BTN_COPY_INVITE": "📋 Link de convite",
"BTN_COPY_INVITE_TOOLTIP": "Copiar link de convite",
"BTN_PLAY": "▶ Reproduzir",
"BTN_PLAY_TOOLTIP": "Enviar um comando de reproduzir para todos",
"BTN_PAUSE": "⏸ Pausar",
"BTN_PAUSE_TOOLTIP": "Enviar um comando de pausar para todos",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Forçar todos os usuários a sincronizar",
"OPTION_JUMP_TO_OTHERS": "Ir para os outros",
"OPTION_JUMP_TO_ME": "Trazer para mim",
"OPTION_JUMP_MODE_TOOLTIP": "Escolher o alvo de sincronização",
"LABEL_LAST_ACTIVITY": "Última atividade",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausar ou buscar",
"NO_RECENT_COMMANDS": "Sem comandos recentes",
"LOBBY_HEADER": "LOBBY DE EPISÓDIO",
"LOBBY_WAITING_FOR": "🎬 Aguardando: \"{title}\"",
"LOBBY_WAITING_PEERS": "Aguardando participantes...",
"BTN_SKIP_PLAY": "Pular e reproduzir",
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar o lobby e reproduzir assim mesmo",
"LOBBY_CONNECT_FIRST": "Entre em uma sala primeiro",
"LOBBY_CONNECT_FIRST_DESC": "Você precisa entrar em uma 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",
"LABEL_USERNAME": "Seu nome de usuário",
"LABEL_USERNAME_TOOLTIP": "O nome de usuário ajuda os outros a identificá-lo.",
"PLACEHOLDER_USERNAME": "Coala anônimo",
"LABEL_HIDE_CLUTTER": "Lista de abas limpa",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra abas que não são de vídeo e domínios não relacionados para manter a lista organizada",
"LABEL_AUTO_SYNC_NEXT": "Sincro auto do próximo episódio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda todos ao mudar de episódio, depois inicia em sincronia.",
"LABEL_AUTO_COPY_INVITE": "Auto-copiar link",
"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 do aplicativo",
"LABEL_LANGUAGE_TOOLTIP": "Escolha o idioma de sua preferência para a extensão",
"LABEL_TROUBLESHOOTING": "Resolução de problemas",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para solucionar problemas de conexão",
"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",
"BTN_RETRY": "REENTRAR",
"BTN_RETRY_TOOLTIP": "Tentar reconectar ao servidor",
"BTN_COPY_LOGS": "Copiar logs",
"BTN_COPY_LOGS_TOOLTIP": "Copiar logs para a área de transferência para compartilhar",
"LABEL_VIDEO_DEBUG": "Informações de depuração de vídeo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o elemento de vídeo selecionado atualmente",
"VIDEO_DEBUG_EMPTY": "Nenhuma aba selecionada ou vídeo detectado.",
"LABEL_HISTORY": "Histórico completo",
"LABEL_HISTORY_TOOLTIP": "Registro cronológico de todos os comandos de sincronização na sala",
"HISTORY_EMPTY": "Sem atividade ainda",
"LABEL_LOGS": "Logs (Últimos 50)",
"LABEL_LOGS_TOOLTIP": "Logs técnicos de conexão para depuração",
"BTN_CLEAR": "LIMPAR",
"BTN_CLEAR_TOOLTIP": "Limpar a saída dos logs",
"LABEL_GITHUB": "Repositório GitHub",
"BTN_ONBOARDING_SKIP": "Pular",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Pular o tutorial",
"BTN_ONBOARDING_NEXT": "Próximo",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir para o próximo passo",
"ONBOARDING_1_TITLE": "Bem-vindo ao KoalaSync!",
"ONBOARDING_1_TEXT": "Assista a vídeos juntos em perfeita sincronia, não importa onde estejam. Vamos fazer um tour rápido!",
"ONBOARDING_2_TITLE": "1. Criar uma sala",
"ONBOARDING_2_TEXT": "Comece aqui. Crie uma sala e compartilhe o link de convite com seus amigos.",
"ONBOARDING_3_TITLE": "2. Selecionar vídeo",
"ONBOARDING_3_TEXT": "Navegue aqui para selecionar o vídeo a ser sincronizado. Reproduza, pause e busque: todos permanecem sincronizados.",
"ONBOARDING_4_TITLE": "3. Personalizar",
"ONBOARDING_4_TEXT": "Escolha um nome de usuário divertido para que seus amigos saibam quem você é.",
"ONBOARDING_5_TITLE": "Tudo pronto!",
"ONBOARDING_5_TEXT": "Hora de pegar a pipoca. Divirta-se assistindo com seus amigos!",
"ERR_CONN_TIMEOUT": "Tempo limite de conexão esgotado. Tente novamente.",
"ERR_INVALID_SERVER_URL": "Formato de URL do servidor inválido.",
"ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento e tente novamente.",
"ERR_NO_PEERS_TIME": "Nenhum outro participante com posição conhecida. Mude para 'Traerlos a mí' / 'Trazer para mim'.",
"ERR_NO_VIDEO_TAB": "Não foi possível conectar à aba do vídeo.",
"ERR_SELECT_VIDEO": "Selecione um vídeo primeiro!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
"TOAST_LOBBY_SKIPPED": "Lobby de 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": "Conectado",
"STATUS_RECONNECTING": "Reconectando...",
"STATUS_CONNECTING": "Conectando...",
"STATUS_FAILED": "Falhou",
"STATUS_DISCONNECTED": "Desconectado",
"BTN_STATE_JOINING": "🚀 Entrando...",
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
"BTN_STATE_PLAYING": "▶ Reproduzindo...",
"BTN_STATE_PAUSING": "⏸ Pausando...",
"BTN_STATE_SYNCING_GROUP": "Sincronizando com o grupo ({time})...",
"BTN_STATE_SYNCING": "Sincronizando...",
"BTN_STATE_SYNCED": "✅ Sincronizado!",
"NOTIF_PLAY": "iniciou a reprodução",
"NOTIF_PAUSE": "pausou a reprodução",
"NOTIF_SEEK": "mudou a posição do vídeo",
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
"DEBUG_NO_TAB": "Nenhuma aba alvo selecionada.",
"DEBUG_COMM_FAIL": "Não foi possível comunicar com o vídeo da aba.",
"EMPTY_PEERS_TITLE": "Nenhum participante ainda",
"EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar",
"EMPTY_HISTORY_TITLE": "Sem atividade ainda",
"EMPTY_HISTORY_HINT": "Reproduza, pause ou busque para ver o histórico",
"EMPTY_LOGS_TITLE": "Sem logs",
"EMPTY_LOGS_HINT": "Os eventos de conexão aparecerão aqui",
"EMPTY_ROOMS_TITLE": "Sem salas ativas",
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize para buscar salas públicas",
"LABEL_YOU": "Você",
"ONBOARDING_DONE": "Concluído!",
"LABEL_LOBBY_PEER_READY": "Pronto",
"LABEL_LOBBY_PEER_LOADING": "Carregando...",
"LABEL_PASSWORD_PROTECTED": "Protegido por senha",
"LABEL_PEERS_COUNT": "{count} participantes",
"LABEL_CUSTOM_SERVER": "Servidor personalizado",
"BTN_STATE_CREATING": "🚀 Criando sala...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Falha na sincronização do episódio",
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Pode ser necessário sincronizar manualmente.",
"LOBBY_CANCEL_TIMEOUT": "Tempo limite esgotado",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo limite esgotado (recuperado)",
"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",
"FOOTER_SUPPORT": "☕ Me pague um café",
"FOOTER_REVIEW": "★ Avaliar",
"FOOTER_SUPPORT_PROMPT": "Gostou do KoalaSync? Deixe uma avaliação!",
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio como compressão à reprodução de vídeo",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
"AUDIO_BACK": "← Voltar",
"AUDIO_PAGE_TITLE": "Configurações de áudio",
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ativado",
"AUDIO_PRESET": "Predefinição",
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
"AUDIO_PRESET_DYNAMIC_RANGE": "Faixa dinâmica",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Aprimoramento de voz",
"AUDIO_PRESET_SMOOTH": "Suave",
"AUDIO_PRESET_CUSTOM": "Personalizado",
"AUDIO_PARAM_THRESHOLD": "Limiar",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizador",
"AUDIO_COMING_SOON": "Em breve"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "pt",
"HTML_CLASS": "lang-pt",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Sala",
"TAB_ROOM_TOOLTIP": "Definições da sala e ligação",
"TAB_SYNC": "Sincronização",
"TAB_SYNC_TOOLTIP": "Controlo de sincronização de vídeo e ações remotas",
"TAB_SETTINGS": "Definições",
"TAB_SETTINGS_TOOLTIP": "Preferências da extensão",
"TAB_STATUS": "Estado",
"TAB_STATUS_TOOLTIP": "Diagnóstico avanzado e registos",
"BTN_CREATE_ROOM": "+ Criar Nova Sala",
"MANUAL_CONNECT_HEADER": "Ligação Manual / Avançado",
"LABEL_SERVER": "Servidor",
"BTN_SERVER_OFFICIAL": "Oficial",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar o servidor oficial fiável",
"BTN_SERVER_CUSTOM": "Personalizado",
"BTN_SERVER_CUSTOM_TOOLTIP": "Ligar ao seu próprio servidor auto-hospedado",
"PLACEHOLDER_SERVER_URL": "wss://o-seu-servidor:3000",
"LABEL_ROOM_ID": "ID da Sala",
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para a sua sala de sincronização",
"PLACEHOLDER_ROOM_ID": "Introduzir ID da Sala",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala à qual deseja aderir",
"LABEL_PASSWORD": "Palavra-passe (Opcional)",
"LABEL_PASSWORD_TOOLTIP": "Palavra-passe opcional para restringir o acesso à sala",
"PLACEHOLDER_PASSWORD": "Palavra-passe da sala (opcional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Palavra-passe para la sala (deixar em branco se não houver)",
"BTN_JOIN_ROOM": "Entrar / Criar Sala",
"BTN_JOIN_ROOM_TOOLTIP": "Ligar à sala",
"LABEL_PUBLIC_ROOMS": "Salas Públicas",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponíveis neste servidor",
"BTN_REFRESH": "ATUALIZAR",
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
"PUBLIC_ROOMS_REFRESHING": "A atualizar...",
"BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A atualização da lista de salas está em espera. Tente novamente em {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "A atualizar salas públicas. Próxima atualização disponível em {seconds}s.",
"LABEL_ACTIVE_ROOM": "Sala Ativa",
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual está atualmente ligado",
"ACTIVE_ROOM_NONE": "NENHUMA",
"ACTIVE_SERVER_OFFICIAL": "Servidor Oficial",
"LABEL_INVITE_LINK": "Link de Invito",
"LABEL_INVITE_LINK_TOOLTIP": "Partilhar este link com os amigos para que possam entrar",
"LABEL_PEERS_IN_ROOM": "Participantes na Sala",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros utilizadores attualmente ligados a questa sala",
"NO_PEERS_CONNECTED": "Nenhum partecipante ligado",
"BTN_LEAVE_ROOM": "Sair della Sala",
"LABEL_SELECT_VIDEO": "Selecionar Vídeo",
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolher o separador do navegador que contém o vídeo a sincronizar",
"OPTION_SELECT_TAB": "-- Selecionar um Separador --",
"LABEL_REMOTE_CONTROL": "Controlo Remoto",
"BTN_COPY_INVITE": "📋 Link de Invito",
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Invito",
"BTN_PLAY": "▶ Reproduzir",
"BTN_PLAY_TOOLTIP": "Enviar um comando de Reproduzir para todos",
"BTN_PAUSE": "⏸ Pausa",
"BTN_PAUSE_TOOLTIP": "Enviar um comando de Pausa para todos",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC_TOOLTIP": "Forçar todos os utilizadores a sincronizarem-se",
"OPTION_JUMP_TO_OTHERS": "Ir para os Outros",
"OPTION_JUMP_TO_ME": "Ir para Mim",
"OPTION_JUMP_MODE_TOOLTIP": "Escolher o destino de sincronização",
"LABEL_LAST_ACTIVITY": "Estado da Última Atividade",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausa ou busca",
"NO_RECENT_COMMANDS": "Nenhum comando recente",
"LOBBY_HEADER": "LOBBY DE EPISÓDIO",
"LOBBY_WAITING_FOR": "🎬 A aguardar: \"{title}\"",
"LOBBY_WAITING_PEERS": "A aguardar participantes...",
"BTN_SKIP_PLAY": "Ignorar e reproduzir mesmo assim",
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar o lobby e reproduzir mesmo assim",
"LOBBY_CONNECT_FIRST": "Ligar-se primeiro a uma sala",
"LOBBY_CONNECT_FIRST_DESC": "Precisa de entrar numa sala através de um link de convite ou criar uma nova para sincronizar vídeos.",
"BTN_CREATE_ROOM_ALT": "Criar Nova Sala",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Criar uma nova sala aleatória e entrar nela",
"LABEL_USERNAME": "O seu Nome de Utilizador",
"LABEL_USERNAME_TOOLTIP": "O nome de utilizador ajuda os outros a identificá-lo.",
"PLACEHOLDER_USERNAME": "Coala Anónimo",
"LABEL_HIDE_CLUTTER": "Ocultar Separadores Desnecessários",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra separadores sem vídeo e domínios não relacionados para manter a lista limpa",
"LABEL_AUTO_SYNC_NEXT": "Sincronização Automática do Próximo Episódio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda por todos os participantes quando um episódio muda, iniciando depois a reprodução em sincronia.",
"LABEL_AUTO_COPY_INVITE": "Copiar Automaticamente o Link de Convite",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link de convite para a área de transferência ao criar uma nova sala.",
"LABEL_NOTIFICATIONS": "Notificações do Navegador",
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notificações do sistema quando alguém entra/sai ou reproduz/pausa.",
"LABEL_LANGUAGE": "Idioma da App",
"LABEL_LANGUAGE_TOOLTIP": "Escolha o seu idioma de preferência para a extensão",
"LABEL_TROUBLESHOOTING": "Resolução de Problemas",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para corrigir problemas de ligação",
"BTN_REGEN_ID": "Regerar ID do Peer",
"BTN_REGEN_ID_TOOLTIP": "Regerar o seu ID interno e voltar a ligar",
"REGEN_ID_DESC": "Use isto se vir erros de \"Identidade Duplicata\".",
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um Issue no GitHub",
"LABEL_CONN_STATUS": "Estado da Ligação",
"LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação WebSocket",
"CONN_STATUS_DISCONNECTED": "Desligado",
"BTN_RETRY": "TENTAR NOVAMENTE",
"BTN_RETRY_TOOLTIP": "Tentar voltar a ligar ao servidor",
"BTN_COPY_LOGS": "Copiar Registos",
"BTN_COPY_LOGS_TOOLTIP": "Copiar registos para a área de transferência para partilha",
"LABEL_VIDEO_DEBUG": "Info de Debug de Vídeo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o elemento de vídeo atualmente seleccionado",
"VIDEO_DEBUG_EMPTY": "Nenhum separador selecionado ou vídeo detetado.",
"LABEL_HISTORY": "Histórico Completo de Ações",
"LABEL_HISTORY_TOOLTIP": "Registo cronológico de todos os comandos de sincronização na sala",
"HISTORY_EMPTY": "Nenhuma atividade ainda",
"LABEL_LOGS": "Registos (Últimos 50)",
"LABEL_LOGS_TOOLTIP": "Registos de ligação técnica para depuração",
"BTN_CLEAR": "LIMPAR",
"BTN_CLEAR_TOOLTIP": "Limpar a saída do registo",
"LABEL_GITHUB": "Repositório GitHub",
"BTN_ONBOARDING_SKIP": "Saltar",
"BTN_ONBOARDING_SKIP_TOOLTIP": "Saltar o tutorial",
"BTN_ONBOARDING_NEXT": "Seguinte",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir para o passo seguinte",
"ONBOARDING_1_TITLE": "Bem-vindo ao KoalaSync!",
"ONBOARDING_1_TEXT": "Veja vídeos em conjunto numa sincronizzazione perfetta — não importa onde esteja. Vamos fare uma breve visita guiada!",
"ONBOARDING_2_TITLE": "1. Criar uma Sala",
"ONBOARDING_2_TEXT": "Comece aqui. Crie uma sala e partilhe o link de convite com os seus amigos.",
"ONBOARDING_3_TITLE": "2. Selecionar Vídeo",
"ONBOARDING_3_TEXT": "Navegue até aqui para selecionar o vídeo que deseja sincronizar. Reproduza, pause e avance — todos ficam em sincronia.",
"ONBOARDING_4_TITLE": "3. Personalizar",
"ONBOARDING_4_TEXT": "Escolha um nome de utilizador divertido para que os seus amigos saibam quem você é.",
"ONBOARDING_5_TITLE": "Está tudo pronto!",
"ONBOARDING_5_TEXT": "Altura de ir buscar pipocas. Divirtam-se a ver juntos!",
"ERR_CONN_TIMEOUT": "Ligação expirada. Tente novamente.",
"ERR_INVALID_SERVER_URL": "Formato de URL do Servidor inválido.",
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento e tente novamente.",
"ERR_NO_PEERS_TIME": "Nenhum outro peer com hora conhecida. Mude para 'Ir para Mim'.",
"ERR_NO_VIDEO_TAB": "Não foi possível ligar ao separador do vídeo.",
"ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
"TOAST_LOBBY_SKIPPED": "Lobby do episódio ignorado.",
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar o lobby.",
"TOAST_LOGS_COPIED": "Copiado!",
"TOAST_PEER_JOINED": "{name} entrou na sala",
"TOAST_PEER_LEFT": "{name} saiu da sala",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Ligado",
"STATUS_RECONNECTING": "A voltar a ligar...",
"STATUS_CONNECTING": "A ligar...",
"STATUS_FAILED": "Falhou",
"STATUS_DISCONNECTED": "Desligado",
"BTN_STATE_JOINING": "🚀 A entrar...",
"BTN_STATE_RECONNECTING": "🔄 A voltar a ligar...",
"BTN_STATE_PLAYING": "▶ A reproduzir...",
"BTN_STATE_PAUSING": "⏸ A pausar...",
"BTN_STATE_SYNCING_GROUP": "A sincronizar com o grupo ({time})....",
"BTN_STATE_SYNCING": "A sincronizar...",
"BTN_STATE_SYNCED": "✅ Sincronizado!",
"NOTIF_PLAY": "iniciou a reprodução",
"NOTIF_PAUSE": "pausou a reprodução",
"NOTIF_SEEK": "avançou/recuou no vídeo",
"NOTIF_FORCE_PREPARE": "iniciou a sincronização forçada",
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
"DEBUG_NO_TAB": "Nenhum separador de destino selecionado.",
"DEBUG_COMM_FAIL": "Não foi possível comunicar com o vídeo do separador.",
"EMPTY_PEERS_TITLE": "Nenhum participante ainda",
"EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar",
"EMPTY_HISTORY_TITLE": "Nenhuma atividade ainda",
"EMPTY_HISTORY_HINT": "Reproduza, pause ou avance para ver o histórico",
"EMPTY_LOGS_TITLE": "Nenhum registo",
"EMPTY_LOGS_HINT": "Os eventi de ligação vão aparecer aqui",
"EMPTY_ROOMS_TITLE": "Nenhuma sala ativa",
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize para encontrar salas públicas",
"LABEL_YOU": "Tu",
"ONBOARDING_DONE": "Concluído!",
"LABEL_LOBBY_PEER_READY": "Pronto",
"LABEL_LOBBY_PEER_LOADING": "A carregar...",
"LABEL_PASSWORD_PROTECTED": "Protegido por Palavra-passe",
"LABEL_PEERS_COUNT": "{count} participantes",
"LABEL_CUSTOM_SERVER": "Servidor Personalizado",
"BTN_STATE_CREATING": "🚀 A Criar Sala...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronização do Episódio Falhou",
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Pode ter de sincronizar manualmente.",
"LOBBY_CANCEL_TIMEOUT": "Timeout",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (recuperado)",
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — nem todos os participantes carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo utilizador",
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
"FOOTER_SUPPORT": "☕ Oferece um café",
"FOOTER_REVIEW": "★ Avaliar",
"FOOTER_SUPPORT_PROMPT": "Gostas do KoalaSync? Deixa uma avaliação!",
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio como compressão à reprodução de vídeo",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
"AUDIO_BACK": "← Voltar",
"AUDIO_PAGE_TITLE": "Configurações de áudio",
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
"AUDIO_COMPRESSOR": "Compressor",
"AUDIO_COMPRESSOR_ENABLE": "Ativado",
"AUDIO_PRESET": "Predefinição",
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
"AUDIO_PRESET_DYNAMIC_RANGE": "Gama dinâmica",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Melhoria vocal",
"AUDIO_PRESET_SMOOTH": "Suave",
"AUDIO_PRESET_CUSTOM": "Personalizado",
"AUDIO_PARAM_THRESHOLD": "Limiar",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizador",
"AUDIO_COMING_SOON": "Em breve"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "ru",
"HTML_CLASS": "lang-ru",
"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://ваш-сервер: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": "Сбросить 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": "Отключено",
"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": "Идентификатор еще не загружен. Подождите немного и повторите попытку.",
"ERR_NO_PEERS_TIME": "Нет других участников с известной позицией плеера. Выберите 'Синхронизировать по мне'.",
"ERR_NO_VIDEO_TAB": "Не удалось подключиться к вкладке с видео.",
"ERR_SELECT_VIDEO": "Пожалуйста, сначала выберите вкладку с видео!",
"TOAST_INVITE_COPIED": "Ссылка скопирована в буфер обмена!",
"TOAST_COPY_FAILED": "Не удалось скопировать ссылку в буфер обмена",
"TOAST_LOBBY_SKIPPED": "Ожидание серии отменено.",
"TOAST_LOBBY_SKIP_FAILED": "Не удалось отменить ожидание серии.",
"TOAST_LOGS_COPIED": "Скопировано!",
"TOAST_PEER_JOINED": "{name} вошел в комнату",
"TOAST_PEER_LEFT": "{name} вышел из комнаты",
"TOAST_PEER_ACTION": "{name}: {action}",
"STATUS_CONNECTED": "Подключено",
"STATUS_RECONNECTING": "Переподключение...",
"STATUS_CONNECTING": "Подключение...",
"STATUS_FAILED": "Ошибка",
"STATUS_DISCONNECTED": "Отключено",
"BTN_STATE_JOINING": "🚀 Вход...",
"BTN_STATE_RECONNECTING": "🔄 Переподключение...",
"BTN_STATE_PLAYING": "▶ Запуск...",
"BTN_STATE_PAUSING": "⏸ Пауза...",
"BTN_STATE_SYNCING_GROUP": "Синхронизация по группе ({time})...",
"BTN_STATE_SYNCING": "Синхронизация...",
"BTN_STATE_SYNCED": "✅ Синхронно!",
"NOTIF_PLAY": "запустил воспроизведение",
"NOTIF_PAUSE": "приостановил воспроизведение",
"NOTIF_SEEK": "перемотал видео",
"NOTIF_FORCE_PREPARE": "запустил принудительный синхрон",
"NOTIF_FORCE_EXECUTE": "синхронизировал воспроизведение у всех",
"DEBUG_NO_TAB": "Целевая вкладка не выбрана.",
"DEBUG_COMM_FAIL": "Не удалось связаться с плеером на вкладке.",
"EMPTY_PEERS_TITLE": "Участников пока нет",
"EMPTY_PEERS_HINT": "Поделитесь ссылкой-приглашением, чтобы начать",
"EMPTY_HISTORY_TITLE": "История действий пуста",
"EMPTY_HISTORY_HINT": "Запускайте, останавливайте или перематывайте видео, чтобы увидеть лог",
"EMPTY_LOGS_TITLE": "Журнал пуст",
"EMPTY_LOGS_HINT": "События подключения будут отображаться здесь",
"EMPTY_ROOMS_TITLE": "Нет активных комнат",
"EMPTY_ROOMS_HINT": "Создайте комнату или обновите список, чтобы найти публичные",
"LABEL_YOU": "Вы",
"ONBOARDING_DONE": "Готово!",
"LABEL_LOBBY_PEER_READY": "Готов",
"LABEL_LOBBY_PEER_LOADING": "Загрузка...",
"LABEL_PASSWORD_PROTECTED": "Защищено паролем",
"LABEL_PEERS_COUNT": "{count} участников",
"LABEL_CUSTOM_SERVER": "Свой сервер",
"BTN_STATE_CREATING": "🚀 Создание комнаты...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Ошибка синхронизации серии",
"NOTIF_LOBBY_CANCEL_MSG": "Автосинхронизация отменена: {reason}. Возможно, вам придется выполнить синхронизацию вручную.",
"LOBBY_CANCEL_TIMEOUT": "Время ожидания истекло",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Время ожидания истекло (восстановлено)",
"LOBBY_CANCEL_PEERS_LEFT": "Все остальные участники вышли",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
"LOBBY_CANCEL_USER": "Отменено пользователем",
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync",
"FOOTER_SUPPORT": "☕ Угости кофе",
"FOOTER_REVIEW": "★ Оценить",
"FOOTER_SUPPORT_PROMPT": "Нравится KoalaSync? Оставьте отзыв!",
"LABEL_AUDIO_PROCESSING": "Обработка звука",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Применяет аудиоэффекты, такие как сжатие, к воспроизведению видео",
"AUDIO_OPEN_SETTINGS": "Открыть",
"NEW_FEATURE_AUDIO": "Новое: Обработка звука — попробуйте компрессор!",
"AUDIO_BACK": "← Назад",
"AUDIO_PAGE_TITLE": "Настройки звука",
"AUDIO_MASTER_TOGGLE": "Обработка звука",
"AUDIO_COMPRESSOR": "Компрессор",
"AUDIO_COMPRESSOR_ENABLE": "Включено",
"AUDIO_PRESET": "Пресет",
"AUDIO_PRESET_RECOMMENDED": "Рекомендуемый",
"AUDIO_PRESET_DYNAMIC_RANGE": "Динамический диапазон",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Улучшение голоса",
"AUDIO_PRESET_SMOOTH": "Плавный",
"AUDIO_PRESET_CUSTOM": "Пользовательский",
"AUDIO_PARAM_THRESHOLD": "Порог",
"AUDIO_PARAM_KNEE": "Knee",
"AUDIO_PARAM_RATIO": "Ratio",
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Эквалайзер",
"AUDIO_COMING_SOON": "Скоро"
}
+209
View File
@@ -0,0 +1,209 @@
{
"LANG_CODE": "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"
}
+6 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.4.5",
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, and HTML5 sites in real-time with friends.",
"version": "2.2.0",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
"tabs",
@@ -18,7 +18,9 @@
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"96": "icons/icon96.png",
"128": "icons/icon128.png"
}
},
@@ -35,7 +37,9 @@
],
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"96": "icons/icon96.png",
"128": "icons/icon128.png"
}
}
+340 -67
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,29 +30,55 @@
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;
}
.header-row {
display: flex;
align-items: center;
margin-bottom: 16px;
}
h1 {
font-size: 18px;
margin: 0 0 16px 0;
margin: 0;
color: var(--accent);
letter-spacing: 1px;
text-transform: uppercase;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
h1 img {
width: 24px;
height: 24px;
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 4px;
}
.popup-version {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text-muted);
text-decoration: none;
opacity: 0.5;
transition: opacity 0.2s, color 0.2s;
}
.popup-version:hover {
opacity: 1;
color: var(--accent);
}
.popup-version svg {
width: 12px;
height: 12px;
display: block;
}
/* Tabs */
.tabs {
display: flex;
@@ -101,7 +134,7 @@
font-weight: 700;
}
input, select {
input, select, option {
width: 100%;
box-sizing: border-box;
background: var(--card);
@@ -113,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);
@@ -199,16 +246,143 @@
}
.invite-box input { flex: 1; font-size: 11px; }
.invite-box button { width: 40px; padding: 0; }
/* Toast Notifications */
#toast-container {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
.toast {
pointer-events: auto;
padding: 10px 16px;
margin-bottom: 6px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
max-width: 280px;
text-align: center;
animation: toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in 2.7s forwards;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.toast-success { background: var(--success); color: white; }
.toast-error { background: var(--error); color: white; }
.toast-info { background: var(--accent); color: white; }
.toast-warning { background: #f59e0b; color: white; }
@keyframes toastSlideIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastFadeOut {
from { opacity: 1; }
to { opacity: 0; transform: translateY(-10px); }
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateX(5px); }
to { opacity: 1; transform: translateX(0); }
}
.tab-active-animate {
animation: fadeSlideIn 0.15s ease-out forwards;
}
/* Toggle Switch CSS */
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #334155;
transition: .3s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
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;
}
.popup-footer {
margin-top: 20px;
text-align: center;
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 10px;
color: var(--text-muted);
font-size: 10px;
opacity: 0.6;
}
.popup-footer a {
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s, opacity 0.2s;
}
.popup-footer a:hover {
color: var(--accent);
opacity: 1;
}
.feature-hint {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-left: 4px;
animation: featurePulse 2s ease-in-out infinite;
cursor: help;
vertical-align: middle;
}
@keyframes featurePulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
</style>
</head>
<body>
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
<div id="toast-container"></div>
<div class="header-row">
<h1 data-i18n="APP_TITLE"><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="popup-version">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span id="popupVersion">v0.0.0</span>
</a>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="tab-room">Room</button>
<button class="tab-btn" data-tab="tab-sync">Sync</button>
<button class="tab-btn" data-tab="tab-settings">Settings</button>
<button class="tab-btn" data-tab="tab-dev">Dev</button>
<button class="tab-btn active" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
<button class="tab-btn" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
<button class="tab-btn" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
</div>
<!-- Room Tab -->
@@ -216,47 +390,52 @@
<!-- JOIN SECTION: Visible when not in a room -->
<div id="section-join">
<button id="createRoomBtn" class="primary" style="margin-bottom: 16px; padding: 14px; font-size: 15px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);" data-i18n="BTN_CREATE_ROOM">+ Create New Room</button>
<details style="margin-bottom: 12px; background: var(--card); border: 1px solid #334155; border-radius: 8px; padding: 8px;">
<summary style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; cursor: pointer; outline: none;" data-i18n="MANUAL_CONNECT_HEADER">Manual Connect / Advanced</summary>
<div style="margin-top: 12px;">
<div class="form-group">
<label>Server</label>
<label title="Select which KoalaSync server to use" data-i18n="LABEL_SERVER">Server</label>
<div style="display:flex; gap:4px; margin-bottom:8px;">
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;">Official</button>
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;">Custom</button>
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_OFFICIAL" data-i18n-title="BTN_SERVER_OFFICIAL_TOOLTIP" title="Use the official reliable server">Official</button>
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_CUSTOM" data-i18n-title="BTN_SERVER_CUSTOM_TOOLTIP" title="Connect to your own self-hosted server">Custom</button>
</div>
<input type="text" id="serverUrl" placeholder="wss://your-server:3000" style="display:none;">
<input type="text" id="serverUrl" data-i18n-placeholder="PLACEHOLDER_SERVER_URL" placeholder="wss://your-server:3000" style="display:none;">
</div>
<div class="form-group">
<label>Room ID</label>
<input type="text" id="roomId" placeholder="Leave empty to create">
<label title="The unique identifier for your sync room" data-i18n="LABEL_ROOM_ID" data-i18n-title="LABEL_ROOM_ID_TOOLTIP">Room ID</label>
<input type="text" id="roomId" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
</div>
<div class="form-group">
<label>Password (Optional)</label>
<input type="password" id="password" placeholder="Room password">
<label title="Optional password to restrict room access" data-i18n="LABEL_PASSWORD" data-i18n-title="LABEL_PASSWORD_TOOLTIP">Password (Optional)</label>
<input type="password" id="password" data-i18n-placeholder="PLACEHOLDER_PASSWORD" data-i18n-title="PLACEHOLDER_PASSWORD_TOOLTIP" placeholder="Room Password (optional)" title="Password for the room (leave empty if none)">
</div>
<div id="roomError" style="display:none; color:var(--error); font-size:11px; margin-bottom:8px; text-align:center;"></div>
<button id="joinBtn" class="primary">Join Room</button>
<button id="createRoomBtn" class="secondary" style="border: 1px solid var(--accent); color: var(--accent);">Create Random Room</button>
<button id="joinBtn" class="primary" data-i18n="BTN_JOIN_ROOM" data-i18n-title="BTN_JOIN_ROOM_TOOLTIP" title="Connect to the room">Join Room</button>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem; margin-bottom: 8px;">
<label style="margin:0;">Public Rooms</label>
<button id="refreshRooms" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">REFRESH</button>
<label style="margin:0;" title="List of publicly available rooms on this server" data-i18n="LABEL_PUBLIC_ROOMS" data-i18n-title="LABEL_PUBLIC_ROOMS_TOOLTIP">Public Rooms</label>
<button id="refreshRooms" class="secondary" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" data-i18n="BTN_REFRESH" data-i18n-title="BTN_REFRESH_TOOLTIP" title="Refresh the list of public rooms">REFRESH</button>
</div>
<div id="publicRooms" class="info-card" style="max-height: 120px; overflow-y: auto; padding: 4px;">
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;">Refreshing...</div>
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;" data-i18n="PUBLIC_ROOMS_REFRESHING">Refreshing...</div>
</div>
</div>
</details>
</div>
<!-- ACTIVE SECTION: Visible when in a room -->
<div id="section-active" style="display:none;">
<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;">Active Room</label>
<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;">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;">Official Server</div>
</div>
<div class="info-card" style="margin-bottom: 20px;">
<label>Invite Link</label>
<label title="Share this link with friends so they can join" data-i18n="LABEL_INVITE_LINK" data-i18n-title="LABEL_INVITE_LINK_TOOLTIP">Invite Link</label>
<div class="invite-box">
<input type="text" id="inviteLink" readonly>
<button id="copyInvite" class="secondary">📋</button>
@@ -264,116 +443,210 @@
</div>
<div style="margin-bottom: 20px;">
<label>Peers in Room</label>
<label title="Other users currently connected to this room" data-i18n="LABEL_PEERS_IN_ROOM" data-i18n-title="LABEL_PEERS_IN_ROOM_TOOLTIP">Peers in Room</label>
<div id="peerList" class="info-card">
<div style="text-align:center; color: var(--text-muted); font-size: 12px;">No peers connected</div>
<div style="text-align:center; color: var(--text-muted); font-size: 12px;" data-i18n="NO_PEERS_CONNECTED">No peers connected</div>
</div>
</div>
<button id="leaveBtn" class="secondary" style="color: var(--error); border-color: var(--error);">Leave Room</button>
<button id="leaveBtn" class="secondary" style="color: var(--error); border-color: var(--error);" data-i18n="BTN_LEAVE_ROOM">Leave Room</button>
</div>
</div>
<!-- Sync Tab -->
<div id="tab-sync" class="tab-content">
<!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active">
<div class="form-group">
<label>Target Tab (Video Source)</label>
<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="">-- Select a Tab --</option>
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
</select>
</div>
<label>Remote Control</label>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="margin: 0;" data-i18n="LABEL_REMOTE_CONTROL">Remote Control</label>
<button id="syncTabCopyInvite" title="Copy Invite Link" style="background:transparent; border: 1px solid #334155; border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor:pointer; opacity:0.8; transition: opacity 0.2s; color: var(--text-muted); display: flex; align-items: center; gap: 4px; white-space: nowrap;" data-i18n="BTN_COPY_INVITE" data-i18n-title="BTN_COPY_INVITE_TOOLTIP">📋 Invite Link</button>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<button id="playBtn" class="primary" style="flex:1; background: var(--success);">▶ Play</button>
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);">⏸ Pause</button>
<button id="playBtn" class="primary" style="flex:1; background: var(--success);" title="Send a Play command to everyone" data-i18n="BTN_PLAY" data-i18n-title="BTN_PLAY_TOOLTIP">▶ Play</button>
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);" title="Send a Pause command to everyone" data-i18n="BTN_PAUSE" data-i18n-title="BTN_PAUSE_TOOLTIP">⏸ Pause</button>
</div>
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); width: 100%; margin-bottom: 15px;">⚡ Force Sync Everyone</button>
<div style="display: flex; gap: 8px; margin-bottom: 15px; align-items: stretch;">
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); flex: 1;" title="Force all users to sync up" data-i18n="BTN_SYNC" data-i18n-title="BTN_SYNC_TOOLTIP">⚡ SYNC</button>
<select id="forceSyncMode" style="flex: 1; background: var(--card); border: 1px solid #334155; color: white; padding: 10px 8px; border-radius: 8px; font-size: 11px; font-family: inherit; cursor: pointer; align-self: stretch;" title="Choose sync target" data-i18n-title="OPTION_JUMP_MODE_TOOLTIP">
<option value="jump-to-others" data-i18n="OPTION_JUMP_TO_OTHERS">Jump to Others</option>
<option value="jump-to-me" data-i18n="OPTION_JUMP_TO_ME">Jump to Me</option>
</select>
</div>
<!-- NEW: Last Action Status Card -->
<label>Last Activity Status</label>
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; min-height: 70px;">
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding-top: 20px;">No recent commands</div>
<label title="Shows the most recent play, pause, or seek command" data-i18n="LABEL_LAST_ACTIVITY" data-i18n-title="LABEL_LAST_ACTIVITY_TOOLTIP">Last Activity Status</label>
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; max-height: 120px; overflow-y: auto;">
<div style="text-align:center; color: var(--text-muted); font-size: 10px;" data-i18n="NO_RECENT_COMMANDS">No recent commands</div>
</div>
<!-- Episode Auto-Sync Lobby Status -->
<div id="episodeLobbyCard" class="info-card" style="display:none; margin-bottom: 15px; border-left: 4px solid var(--star); animation: fadeIn 0.3s ease-out;">
<div style="display:flex; align-items:center; gap: 6px; margin-bottom: 6px;">
<span style="font-size: 16px;"></span>
<span style="font-weight: 700; color: var(--star); font-size: 12px;">EPISODE LOBBY</span>
<span style="font-weight: 700; color: var(--star); font-size: 12px;" data-i18n="LOBBY_HEADER">EPISODE LOBBY</span>
</div>
<div id="lobbyTitle" style="font-size: 11px; color: var(--text); margin-bottom: 6px; font-weight: 600;"></div>
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted);"></div>
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted); margin-bottom: 8px;"></div>
<button id="cancelLobbyBtn" class="secondary" style="margin-top: 4px; padding: 6px 10px; font-size: 11px; width: auto; display: block;" title="Cancel lobby and play anyway" data-i18n="BTN_SKIP_PLAY" data-i18n-title="BTN_SKIP_PLAY_TOOLTIP">Skip & Play anyway</button>
</div>
<div id="peerListSync" class="info-card" style="display:none;"></div>
</div>
<!-- SYNC INACTIVE: Visible when not in a room -->
<div id="sync-inactive" style="display:none; text-align: center; padding: 20px 10px;">
<div style="font-size: 32px; margin-bottom: 12px;">🔒</div>
<h3 style="margin: 0 0 8px 0; color: var(--accent); font-size: 15px;" data-i18n="LOBBY_CONNECT_FIRST">Connect to a room first</h3>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 20px;" data-i18n="LOBBY_CONNECT_FIRST_DESC">You need to join a room via an invite link or create a new one to sync videos.</p>
<button id="syncTabCreateRoomBtn" class="primary" style="padding: 12px; font-size: 14px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);" title="Create a new random room and join it" data-i18n="BTN_CREATE_ROOM_ALT" data-i18n-title="BTN_CREATE_ROOM_ALT_TOOLTIP">Create New Room</button>
</div>
</div>
<!-- Settings Tab -->
<div id="tab-settings" class="tab-content">
<div class="form-group">
<label>Your Username</label>
<input type="text" id="username" placeholder="Anonymous Koala" maxlength="20">
<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;" title="Username helps others identify you." data-i18n="LABEL_USERNAME" data-i18n-title="LABEL_USERNAME_TOOLTIP">Your Username</label>
<input type="text" id="username" data-i18n-placeholder="PLACEHOLDER_USERNAME" placeholder="Anonymous Koala" maxlength="20" style="width: 150px;">
</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;">Filter Noise Tabs</label>
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
<label style="margin-bottom: 0; cursor: help;" title="Filters out non-video tabs and unrelated domains to keep the list clean" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label>
<label class="toggle-switch">
<input type="checkbox" id="filterNoise" checked>
<span class="slider"></span>
</label>
</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;">Auto-Sync Next Episode</label>
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
<label style="margin-bottom: 0; cursor: help;" title="Pauses automatically and waits for all peers when an episode changes, then sync-starts together." data-i18n="LABEL_AUTO_SYNC_NEXT" data-i18n-title="LABEL_AUTO_SYNC_NEXT_TOOLTIP">Auto-Sync Next Episode</label>
<label class="toggle-switch">
<input type="checkbox" id="autoSyncNextEpisode">
<span class="slider"></span>
</label>
</div>
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
<p>• Username helps others identify you.</p>
<p>• Noise filtering uses a blacklist to hide common non-video sites (e.g. Search, Social Media) from the Target Tab selector.</p>
<p>• Auto-Sync will pause and wait for all peers when an episode changes, then sync-start together.</p>
<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; cursor: help;" title="Automatically copies the invite link to your clipboard when creating a new room." data-i18n="LABEL_AUTO_COPY_INVITE" data-i18n-title="LABEL_AUTO_COPY_INVITE_TOOLTIP">Auto-Copy Invite Link</label>
<label class="toggle-switch">
<input type="checkbox" id="autoCopyInvite" checked>
<span class="slider"></span>
</label>
</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; cursor: help;" title="Shows native system notifications when someone joins/leaves or plays/pauses." data-i18n="LABEL_NOTIFICATIONS" data-i18n-title="LABEL_NOTIFICATIONS_TOOLTIP">Browser Notifications</label>
<label class="toggle-switch">
<input type="checkbox" id="browserNotifications">
<span class="slider"></span>
</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: 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>Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;">Regenerate Peer ID</button>
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;">Use this if you see "Duplicate Identity" errors.</p>
<label title="Tools for fixing connection issues" data-i18n="LABEL_TROUBLESHOOTING" data-i18n-title="LABEL_TROUBLESHOOTING_TOOLTIP">Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;" data-i18n="REGEN_ID_DESC">Use this if you see "Duplicate Identity" errors.</p>
<p style="font-size: 9px; text-align: center; margin-top: 6px;"><a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" style="color: var(--accent); text-decoration: none;" data-i18n="REGEN_ID_OTHER_ISSUE">Other issue? Open a GitHub Issue</a></p>
</div>
<div class="popup-footer">
<div style="font-size: 9px; color: var(--text-muted); opacity: 0.6; margin-bottom: 6px; line-height: 1.3;" data-i18n="FOOTER_SUPPORT_PROMPT">Enjoying KoalaSync? Leave a review!</div>
<a id="settingsReviewLink" href="#" target="_blank" data-i18n="FOOTER_REVIEW">★ Rate us</a>
<span> · </span>
<a id="settingsSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">☕ Buy me a coffee</a>
<span> · </span>
<span id="settingsVersion">v0.0.0</span>
</div>
</div>
<!-- Dev Tab -->
<div id="tab-dev" class="tab-content">
<label>Connection Status</label>
<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;">Disconnected</span>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;">Copy Logs</button>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
</div>
<label>Video Debug Info</label>
<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;">
No tab selected or video detected.
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<label>Full Action History</label>
<label title="Chronological log of all sync commands in the room" data-i18n="LABEL_HISTORY" data-i18n-title="LABEL_HISTORY_TOOLTIP">Full Action History</label>
</div>
<div id="historyList" class="info-card" style="max-height: 120px; overflow-y: auto; font-size: 10px; margin-bottom: 15px;">
<div style="text-align:center; color: var(--text-muted); font-size: 11px;">No activity yet</div>
<div style="text-align:center; color: var(--text-muted); font-size: 11px;" data-i18n="HISTORY_EMPTY">No activity yet</div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<label>Logs (Last 50)</label>
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
<label title="Technical connection logs for debugging" data-i18n="LABEL_LOGS" data-i18n-title="LABEL_LOGS_TOOLTIP">Logs (Last 50)</label>
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" title="Clear log output" data-i18n="BTN_CLEAR" data-i18n-title="BTN_CLEAR_TOOLTIP">CLEAR</button>
</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;">
<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;">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 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 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>
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
</div>
</div>
</body>
</html>
+1318 -253
View File
File diff suppressed because it is too large Load Diff
+2141 -4
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -1,13 +1,20 @@
{
"name": "koalasync",
"version": "1.4.5",
"version": "2.2.0",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
"build:extension": "node scripts/build-extension.js"
"build:extension": "node scripts/build-extension.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
"archiver": "^7.0.1",
"fs-extra": "^11.2.0"
"esbuild": "^0.28.0",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0",
"sharp": "^0.34.5",
"svgo": "^4.0.1"
}
}
+2 -2
View File
@@ -22,7 +22,7 @@ if (!fs.existsSync(extSharedDir)) {
fs.mkdirSync(extSharedDir, { recursive: true });
}
const sharedFiles = ['constants.js', 'blacklist.js', 'README.md'];
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md'];
for (const file of sharedFiles) {
const src = path.join(masterSharedDir, file);
const dest = path.join(extSharedDir, file);
@@ -31,7 +31,7 @@ for (const file of sharedFiles) {
}
fs.copyFileSync(src, dest);
}
console.log('✓ constants.js, blacklist.js, and README.md synced to extension/shared/');
console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to extension/shared/');
// Read the base manifest
const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
+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');
+64
View File
@@ -0,0 +1,64 @@
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const contentPath = path.join(__dirname, '..', 'extension', 'content.js');
const source = fs.readFileSync(contentPath, 'utf8');
function extractFunction(name, text) {
const start = text.indexOf(`function ${name}`);
assert.notStrictEqual(start, -1, `${name} not found`);
const bodyStart = text.indexOf('{', start);
let depth = 0;
for (let i = bodyStart; i < text.length; i++) {
if (text[i] === '{') depth++;
if (text[i] === '}') depth--;
if (depth === 0) return text.slice(start, i + 1);
}
throw new Error(`${name} body did not terminate`);
}
function makeVideo(name, width, height, options = {}) {
return {
name,
tagName: 'VIDEO',
videoWidth: width,
videoHeight: height,
offsetWidth: width,
offsetHeight: height,
muted: options.muted ?? true,
duration: options.duration ?? 0
};
}
const lightPreview = makeVideo('light-preview', 160, 90, { muted: false, duration: 30 });
const shadowPlayer = makeVideo('shadow-player', 1920, 1080, { muted: false, duration: 3600 });
const shadowRoot = {
querySelectorAll(selector) {
if (selector === 'video') return [shadowPlayer];
return [];
}
};
const shadowHost = { shadowRoot };
const fakeDocument = {
querySelectorAll(selector) {
if (selector === 'video') return [lightPreview];
return [shadowHost];
}
};
const fnSource = extractFunction('findVideo', source);
const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument);
const selected = findVideo(fakeDocument);
assert.strictEqual(
selected,
shadowPlayer,
'findVideo should score Shadow DOM videos together with light DOM videos'
);
console.log('content video finder tests passed');
+108
View File
@@ -0,0 +1,108 @@
const fs = require('fs');
const path = require('path');
const localesDir = path.join(__dirname, '..', 'extension', 'locales');
const enPath = path.join(localesDir, 'en.json');
if (!fs.existsSync(enPath)) {
console.error('CRITICAL: en.json is missing!');
process.exit(1);
}
let hasError = false;
// Verify SUPPORTED_LANGUAGES in extension/i18n.js matches JSON files
const i18nPath = path.join(__dirname, '..', 'extension', 'i18n.js');
try {
if (fs.existsSync(i18nPath)) {
const i18nContent = fs.readFileSync(i18nPath, 'utf8');
const langMatch = i18nContent.match(/export const SUPPORTED_LANGUAGES = \[(.*?)\];/);
if (!langMatch) {
hasError = true;
console.error('❌ Could not parse SUPPORTED_LANGUAGES from extension/i18n.js');
} else {
const supportedLangs = langMatch[1].split(',').map(s => s.trim().replace(/['"]/g, ''));
const fileLangs = fs.readdirSync(localesDir)
.filter(file => file.endsWith('.json'))
.map(file => file.replace('.json', ''));
for (const lang of fileLangs) {
if (!supportedLangs.includes(lang)) {
hasError = true;
console.error(`${lang}.json exists in extension/locales but is missing from SUPPORTED_LANGUAGES in extension/i18n.js`);
}
}
for (const lang of supportedLangs) {
if (!fileLangs.includes(lang)) {
hasError = true;
console.error(`${lang} is in SUPPORTED_LANGUAGES in extension/i18n.js but ${lang}.json is missing from extension/locales`);
}
}
}
}
} catch (err) {
hasError = true;
console.error('❌ Failed to verify SUPPORTED_LANGUAGES synchronization:', err.message);
}
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
const enKeys = Object.keys(enDict);
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json') && file !== 'en.json');
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 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));
if (missingKeys.length > 0 || extraKeys.length > 0) {
hasError = true;
console.error(`${file} has inconsistencies:`);
if (missingKeys.length > 0) {
console.error(` Missing keys (${missingKeys.length}):`, missingKeys);
}
if (extraKeys.length > 0) {
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
}
} else {
console.log(`${file} is fully consistent (matches all keys).`);
}
} catch (err) {
hasError = true;
console.error(`❌ Failed to parse ${file}:`, err.message);
}
}
console.log('');
if (hasError) {
console.error('❌ Locale consistency check failed! Please fix the errors listed above.');
process.exit(1);
} else {
console.log('🎉 All locale files are perfectly synchronized and consistent!');
process.exit(0);
}
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { cwd } from 'node:process';
const popupPath = path.join(cwd(), 'extension', 'popup.js');
const source = fs.readFileSync(popupPath, 'utf8');
assert.match(
source,
/const ROOM_LIST_REFRESH_COOLDOWN_MS\s*=\s*11000;/,
'popup should define an 11 second room-list refresh cooldown'
);
assert.match(
source,
/elements\.refreshRooms\.disabled\s*=\s*true;/,
'refresh button should be disabled while cooldown is active'
);
assert.match(
source,
/setTimeout\(\(\)\s*=>\s*{\s*elements\.refreshRooms\.disabled\s*=\s*false;/s,
'refresh button should be re-enabled after the cooldown'
);
console.log('popup refresh cooldown tests passed');
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from '../server/ops.js';
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics');
const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token');
assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics');
const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token');
assert.equal(correctAuth, true, 'correct bearer token should authorize metrics');
const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', '');
assert.equal(disabledAuth, false, 'empty admin token disables admin metrics');
assert.equal(isAdminMetricsTokenStrong(''), true, 'empty admin token is allowed because metrics stay disabled');
assert.equal(isAdminMetricsTokenStrong('short-token'), false, 'short admin token should be reported as weak');
assert.equal(
isAdminMetricsTokenStrong('a'.repeat(32)),
true,
'admin token with at least 32 characters should be considered strong'
);
const cooldowns = new Map();
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
const cache = new Map();
let buildCalls = 0;
const firstCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 1_000);
const secondCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 30_000);
const expiredCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 61_001);
assert.deepEqual(firstCached, { value: 1 }, 'cache should return the builder payload on first request');
assert.strictEqual(secondCached, firstCached, 'cache should reuse payloads inside the ttl');
assert.deepEqual(expiredCached, { value: 2 }, 'cache should rebuild payloads after ttl expiry');
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
const basicHealth = buildHealthPayload({
rooms,
connections: 5,
includeMetrics: false,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
});
assert.deepEqual(
Object.keys(basicHealth).sort(),
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(),
'basic health should not expose extended metrics'
);
const adminHealth = buildHealthPayload({
rooms,
connections: 5,
includeMetrics: true,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
});
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies');
assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size');
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
assert.deepEqual(
adminHealth.rateLimitEntries,
{ connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
'admin metrics should expose aggregate rate-limit map sizes'
);
console.log('server ops tests passed');
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import {
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
HEALTH_RATE_LIMIT_PER_MINUTE,
healthCounts,
adminMetricsAuthCounts,
healthResponseCache,
httpServer,
rooms,
startServer,
stopServerForTests
} from '../server/index.js';
const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
const baseHeaders = { 'x-forwarded-for': '203.0.113.10' };
function url(path) {
const address = httpServer.address();
return `http://127.0.0.1:${address.port}${path}`;
}
async function request(path, options = {}) {
return fetch(url(path), {
...options,
headers: {
...baseHeaders,
...(options.headers || {})
}
});
}
try {
await startServer(0, '127.0.0.1');
let res = await request('/');
assert.equal(res.status, 200, 'root health endpoint should respond');
assert.equal(res.headers.get('cache-control'), 'no-store', 'root response should disable HTTP caching');
assert.deepEqual(await res.json(), { status: 'online', service: 'KoalaSync Relay' });
res = await request('/health');
assert.equal(res.status, 200, 'basic health endpoint should respond');
assert.equal(res.headers.get('cache-control'), 'no-store', 'basic health response should disable HTTP caching');
const basicHealth = await res.json();
assert.equal(basicHealth.status, 'ok', 'basic health should report ok');
assert.equal(basicHealth.rooms, 0, 'basic health should include room count');
assert.equal('peers' in basicHealth, false, 'basic health should not expose admin metrics');
assert.equal('memory' in basicHealth, false, 'basic health should not expose memory metrics');
rooms.set('route-test-room', {
peers: new Set(['socket-a', 'socket-b']),
peerData: new Map(),
peerIds: new Map(),
activeLobby: { expectedTitle: 'Episode 1', initiatorPeerId: 'peer-a', readyPeers: ['peer-a'] }
});
healthResponseCache.clear();
healthCounts.clear();
res = await request('/health', {
headers: { authorization: `Bearer ${adminToken}`, 'x-forwarded-for': '203.0.113.20' }
});
assert.equal(res.status, 200, 'authorized admin health endpoint should respond');
const adminHealth = await res.json();
assert.equal(adminHealth.rooms, 1, 'admin health should include room count');
assert.equal(adminHealth.peers, 2, 'admin health should include aggregate peer count');
assert.equal(adminHealth.roomsWithLobby, 1, 'admin health should include aggregate lobby count');
assert.equal(typeof adminHealth.memory?.rss, 'number', 'admin health should include aggregate memory metrics');
assert.equal('route-test-room' in adminHealth, false, 'admin health should not expose room identifiers');
healthCounts.clear();
adminMetricsAuthCounts.clear();
for (let i = 0; i < ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE; i++) {
res = await request('/health', {
headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' }
});
assert.equal(res.status, 200, `wrong admin bearer attempt ${i + 1} should still return basic health`);
}
res = await request('/health', {
headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' }
});
assert.equal(res.status, 429, 'wrong admin bearer attempts should be throttled after the limit');
healthCounts.clear();
for (let i = 0; i < HEALTH_RATE_LIMIT_PER_MINUTE; i++) {
const path = i % 2 === 0 ? '/' : '/health';
res = await request(path, { headers: { 'x-forwarded-for': '203.0.113.40' } });
assert.equal(res.status, 200, `shared health request ${i + 1} should be allowed`);
}
res = await request('/', { headers: { 'x-forwarded-for': '203.0.113.40' } });
assert.equal(res.status, 429, 'root and health should share the public health rate limit');
console.log('server route tests passed');
} finally {
await stopServerForTests();
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const localesDir = path.join(__dirname, '..', 'website', 'locales');
const enPath = path.join(localesDir, 'en.json');
if (!fs.existsSync(enPath)) {
console.error('CRITICAL: website/locales/en.json is missing!');
process.exit(1);
}
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
const enKeys = Object.keys(enDict).sort();
const localeFiles = fs.readdirSync(localesDir)
.filter(file => file.endsWith('.json') && file !== 'en.json')
.sort();
let hasError = false;
console.log(`Auditing website i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
for (const file of localeFiles) {
const filePath = path.join(localesDir, file);
try {
const raw = fs.readFileSync(filePath, 'utf8');
// Check for duplicate keys in raw JSON before parsing
const keyRe = /"(\w+)"\s*:/g;
const seenKeys = {};
let dupes = [];
let m;
while ((m = keyRe.exec(raw)) !== null) {
if (seenKeys[m[1]]) {
dupes.push(m[1]);
}
seenKeys[m[1]] = true;
}
if (dupes.length > 0) {
hasError = true;
console.error(`${file} has duplicate keys: ${[...new Set(dupes)].join(', ')}`);
continue;
}
const dict = JSON.parse(raw);
const keys = Object.keys(dict).sort();
const missingKeys = enKeys.filter(k => !keys.includes(k));
const extraKeys = keys.filter(k => !enKeys.includes(k));
if (missingKeys.length > 0 || extraKeys.length > 0) {
hasError = true;
console.error(`${file} has inconsistencies:`);
if (missingKeys.length > 0) {
console.error(` Missing keys (${missingKeys.length}):`, missingKeys);
}
if (extraKeys.length > 0) {
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
}
} else {
console.log(`${file} is fully consistent (matches all keys).`);
}
} catch (err) {
hasError = true;
console.error(`❌ Failed to parse ${file}:`, err.message);
}
}
console.log('');
if (hasError) {
console.error('❌ Website locale consistency check failed! Fix the errors above.');
process.exit(1);
} else {
console.log('🎉 All website locale files are perfectly synchronized and consistent!');
process.exit(0);
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
['server ops', 'node', ['scripts/test-server-ops.mjs']],
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
}],
['content video finder', 'node', ['scripts/test-content-video-finder.js']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
['content syntax', 'node', ['-c', 'extension/content.js']],
['popup syntax', 'node', ['-c', 'extension/popup.js']],
['background syntax', 'node', ['-c', 'extension/background.js']],
['locale coverage', 'node', ['scripts/test-locales.js']],
['website locale coverage', 'node', ['scripts/test-website-locales.mjs']],
['lint', 'npm', ['run', 'lint']],
['root production audit', 'npm', ['audit', '--omit=dev']],
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
['extension build', 'npm', ['run', 'build:extension']],
['website build', 'node', ['website/build.js']]
];
function runCheck([label, command, args, options = {}]) {
return new Promise((resolve, reject) => {
console.log(`\n==> ${label}`);
const child = spawn(command, args, {
cwd: options.cwd || repoRoot,
env: { ...process.env, ...(options.env || {}) },
stdio: 'inherit',
shell: process.platform === 'win32'
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${label} failed with exit code ${code}`));
});
});
}
for (const check of checks) {
await runCheck(check);
}
console.log('\nRelease verification passed');
+10 -4
View File
@@ -1,4 +1,10 @@
PORT=3000
MIN_VERSION=1.0.0
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=50
PORT="3000"
MIN_VERSION="1.0.0"
MAX_ROOMS="1000"
MAX_PEERS_PER_ROOM="25"
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=""
# Optional: set to "1" to enable verbose connection, room-join/leave, and CORS logs in the console. Default is "0" (disabled).
DEBUG_LOGGING="0"
+38 -8
View File
@@ -1,23 +1,50 @@
# KoalaSync Relay Server
A high-performance Node.js relay server for synchronized video playback.
A Node.js relay server for synchronized video playback.
## Key Features
- **Zero-Persistence**: No database. All state is held in RAM.
- **Privacy First**: No tracking, no logging of user data.
- **WebSocket Only**: High performance with minimal overhead.
- **Privacy First**: No tracking, no persistent logging of user data.
- **WebSocket Only**: Minimal overhead with efficient transport.
## Setup
### Environment
Copy `.env.example` to `.env` and configure your settings.
```bash
PORT=3000
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=50
MIN_VERSION=1.0.0
PORT="3000"
MAX_ROOMS="1000"
MAX_PEERS_PER_ROOM="25"
MIN_VERSION="1.0.0"
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=""
# Optional: set to "1" to enable verbose connection, room-join/leave, and CORS logs in the console. Default is "0" (disabled).
DEBUG_LOGGING="0"
```
### Health & Metrics
`GET /` and `GET /health` are IP-rate-limited to 10 requests per minute per client IP. These health-style responses are cached server-side for 60 seconds and refreshed lazily on request. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer <token>` receive additional aggregate metrics such as total peers, average peers per room, max room size, active lobby count, rate-limit map sizes, and process memory usage. Wrong admin bearer attempts are separately limited to 5 requests per minute per client IP. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
Use a long random `ADMIN_METRICS_TOKEN` of at least 32 characters. Shorter configured tokens still work, but the server logs a startup warning.
Generate a token with one of these commands:
```bash
openssl rand -base64 32
# or
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```
For Docker Compose deployments, set the generated value in `server/.env`:
```bash
ADMIN_METRICS_TOKEN=replace-with-a-long-random-token
```
When polling `/health` from Prometheus, Uptime Kuma, cron, or a load balancer, keep the interval comfortably below the public limit of 10 requests per minute per client IP. A 30-60 second interval is recommended for routine monitoring. Use the admin bearer token only from trusted monitoring hosts, and keep the Node server private behind Caddy or another trusted reverse proxy because IP-based limits depend on the configured proxy boundary.
### Docker (Recommended)
The server is available as a pre-built image on GHCR.
```bash
@@ -27,7 +54,7 @@ docker pull ghcr.io/shik3i/koalasync:latest
# Or build from the repository root
docker build -t koala-sync-server -f server/Dockerfile .
```
See [docker-compose.example.yml](../docker-compose.example.yml) in the root directory for a ready-to-use configuration.
See [Docker network compose](../examples/docker-compose.caddy.example.yml) or [Static IP compose](../examples/docker-compose.ip.example.yml) in the root directory for ready-to-use Docker Compose files.
### Manual Setup
```bash
@@ -38,6 +65,9 @@ npm start
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 10 requests per minute per IP, with 60-second lazy server-side response caching and stricter throttling for wrong admin bearer attempts.
- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket.
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
- **Reverse Proxy Boundary**: The server trusts one reverse proxy hop for client IP detection. Keep the Node port private/firewalled so clients can only reach it through Caddy or another trusted proxy.
+395 -96
View File
@@ -1,32 +1,108 @@
import express from 'express';
import { createServer } from 'http';
import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN } from '../shared/constants.js';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from './ops.js';
dotenv.config();
function hashPassword(password) {
if (!password) return null;
const salt = process.env.SERVER_SALT || 'koalasync_salt_3i';
return crypto.createHmac('sha256', salt).update(password).digest('hex');
}
const PORT = process.env.PORT || 3000;
const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 50;
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25;
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || '';
const ROOM_LIST_COOLDOWN_MS = 10000;
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
const HEALTH_RESPONSE_CACHE_TTL_MS = 60000;
const app = express();
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
}
export const app = express();
app.set('trust proxy', 1); // For real client IP through reverse proxy
// Health Check
app.get('/', (req, res) => res.json({ status: 'online', service: 'KoalaSync Relay' }));
export const healthResponseCache = new Map();
const httpServer = createServer(app);
// Health Check with Rate Limiting
app.get('/', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Too many requests. Try again later.' });
}
res.set('Cache-Control', 'no-store');
res.json(getCachedPayload(
healthResponseCache,
'root',
HEALTH_RESPONSE_CACHE_TTL_MS,
() => ({ status: 'online', service: 'KoalaSync Relay' })
));
});
app.get('/health', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
const authHeader = req.get('authorization');
const includeMetrics = isAdminMetricsAuthorized(authHeader, ADMIN_METRICS_TOKEN);
if (ADMIN_METRICS_TOKEN && authHeader && !includeMetrics && !checkAdminMetricsAuthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
res.set('Cache-Control', 'no-store');
res.json(getCachedPayload(
healthResponseCache,
includeMetrics ? 'health-admin' : 'health-basic',
HEALTH_RESPONSE_CACHE_TTL_MS,
() => buildHealthPayload({
rooms,
connections: io.engine?.clientsCount ?? 0,
includeMetrics,
uptime: process.uptime(),
rateLimitSizes: {
connections: connectionCounts.size,
events: eventCounts.size,
health: healthCounts.size,
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
})
));
});
export const httpServer = createServer(app);
// Socket.IO setup with security constraints
const io = new Server(httpServer, {
export const io = new Server(httpServer, {
cors: {
origin: ["https://sync.koalastuff.net"],
origin: (origin, callback) => {
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
callback(null, true);
} else {
log('CORS', `Rejected origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
}
},
methods: ["GET", "POST"]
},
maxHttpBufferSize: 1024, // 1KB max per message
maxHttpBufferSize: 4096, // 4KB max per message (headroom for JOIN_ROOM payloads)
transports: ['websocket'],
allowUpgrades: false
});
@@ -34,17 +110,22 @@ const io = new Server(httpServer, {
/**
* In-memory storage
*/
const rooms = new Map();
export const rooms = new Map();
const socketToRoom = new Map();
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
function log(type, message, details = '') {
const debugLogging = process.env.DEBUG_LOGGING === '1';
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS';
if (!debugLogging && isVerbose) return;
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${type}] ${message}`, details);
}
// Rate Limiting & Security
const connectionCounts = new Map(); // ip -> { count, resetTime }
export const connectionCounts = new Map(); // ip -> { count, resetTime }
const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
function checkAuthRate(ip, roomId) {
@@ -66,27 +147,53 @@ function checkAuthRate(ip, roomId) {
}
function recordAuthFailure(ip, roomId) {
if (failedAuthAttempts.size > 50000) {
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
}
}
// 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.');
for (const [key] of failedAuthAttempts.entries()) {
if (failedAuthAttempts.size <= 40000) {
break;
}
failedAuthAttempts.delete(key);
}
}
}
const key = `${ip}:${roomId}`;
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
record.count++;
record.lastAttempt = Date.now();
failedAuthAttempts.delete(key); // Remove first to update insertion order (moves key to the end of iteration)
failedAuthAttempts.set(key, record);
}
// Periodically clean up old auth failure records (every hour)
setInterval(() => {
// Periodically clean up old auth failure records (every 15 minutes)
const authFailureCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, record] of failedAuthAttempts.entries()) {
if (now - record.lastAttempt > 60 * 60 * 1000) {
if (now - record.lastAttempt > 15 * 60 * 1000) {
failedAuthAttempts.delete(key);
}
}
}, 60 * 60 * 1000);
}, 15 * 60 * 1000);
const eventCounts = new Map(); // socketId -> { count, resetTime }
export const eventCounts = new Map(); // socketId -> { count, resetTime }
export const healthCounts = new Map(); // ip -> { count, resetTime }
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
// Clean up connection counts and event counts to prevent memory leak
setInterval(() => {
const rateLimitCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [ip, entry] of connectionCounts.entries()) {
if (now > entry.resetTime) {
@@ -94,10 +201,25 @@ setInterval(() => {
}
}
for (const [socketId, entry] of eventCounts.entries()) {
if (now > entry.resetTime) {
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
eventCounts.delete(socketId);
}
}
for (const [ip, entry] of healthCounts.entries()) {
if (now > entry.resetTime) {
healthCounts.delete(ip);
}
}
for (const [ip, entry] of adminMetricsAuthCounts.entries()) {
if (now > entry.resetTime) {
adminMetricsAuthCounts.delete(ip);
}
}
for (const [socketId] of roomListCooldowns.entries()) {
if (!io.sockets.sockets.has(socketId)) {
roomListCooldowns.delete(socketId);
}
}
}, 60000);
function checkConnectionRate(ip) {
@@ -118,6 +240,24 @@ function checkEventRate(socketId) {
return entry.count <= 30;
}
function checkHealthRate(ip) {
const now = Date.now();
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
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;
}
function checkAdminMetricsAuthRate(ip) {
const now = Date.now();
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
adminMetricsAuthCounts.set(ip, entry);
return entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE;
}
/**
* Central peer teardown. Removes a socket from all room state and notifies
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
@@ -125,12 +265,8 @@ function checkEventRate(socketId) {
* @param {string} socketId - The socket.id being removed.
* @param {string} roomId - The room it belongs to.
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
* @param {boolean} [emitLeave=true] - Set false when the socket.io room leave
* is handled by the caller (e.g. reaper calls
* socket.leave() before us, or dedupe calls
* oldSocket.leave() before disconnecting).
*/
function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
function removePeerFromRoom(socketId, roomId, reason) {
const room = rooms.get(roomId);
if (!room) return;
@@ -146,13 +282,25 @@ function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
// 2. Remove from global maps
socketToRoom.delete(socketId);
if (peerToSocket.get(peerId) === socketId) {
const currentSocketId = peerToSocket.get(peerId);
if (currentSocketId === socketId) {
peerToSocket.delete(peerId);
}
// 3. Notify remaining peers (use io.to so the removed socket itself
// doesn't receive it — it has already left or is disconnecting)
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId);
if (!isPeerStillConnected) {
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
}
// 3.5. Clean up active lobby if a peer leaves
if (room.activeLobby) {
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
room.activeLobby = null; // Dissolve lobby
}
}
// 4. Delete empty room
if (room.peers.size === 0) {
@@ -164,7 +312,10 @@ function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
}
io.on('connection', (socket) => {
const clientIp = socket.handshake.address;
// Get real client IP behind proxy/CDN
const forwardedFor = socket.handshake.headers['x-forwarded-for'];
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : socket.handshake.address;
socket._clientIp = clientIp;
// 1. Connection Rate Limit
if (!checkConnectionRate(clientIp)) {
@@ -185,7 +336,14 @@ io.on('connection', (socket) => {
}
if (clientVersion) {
const [cMaj, cMin, cPatch] = clientVersion.split('.').map(Number);
if (typeof clientVersion !== 'string') {
log('AUTH', `Invalid version type from ${clientIp}`);
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
socket.disconnect(true);
return;
}
const parts = clientVersion.split('.').map(Number);
const cMaj = parts[0], cMin = parts[1], cPatch = parts[2] || 0;
const [mMaj, mMin, mPatch] = MIN_VERSION.split('.').map(Number);
if (isNaN(cMaj) || isNaN(cMin) || isNaN(cPatch)) {
log('AUTH', `Invalid version format (${clientVersion}) from ${clientIp}`);
@@ -225,7 +383,7 @@ io.on('connection', (socket) => {
try {
// Protocol check
if (protocolVersion !== '1.0.0') {
if (protocolVersion !== PROTOCOL_VERSION) {
log('AUTH', `Protocol mismatch from ${peerId}: ${protocolVersion}`);
socket.emit(EVENTS.ERROR, { message: 'Incompatible protocol version' });
return;
@@ -233,64 +391,102 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId) {
return; // Already in this room, ignore to prevent spam
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
return; // Already in this room with same peerId, ignore to prevent spam
}
if (oldMapping && oldMapping.roomId !== roomId) {
socket.leave(oldMapping.roomId);
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
}
const ip = socket.handshake.address;
const ip = socket._clientIp || socket.handshake.address;
if (!checkAuthRate(ip, roomId)) {
log('AUTH', `Auth rate limit blocked ${ip} from room ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Too many failed attempts. Try again later." });
return;
}
let room = rooms.get(roomId);
let createdByMe = false;
if (!room) {
if (rooms.size >= MAX_ROOMS) {
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
return;
// Acquire per-room creation lock to prevent race conditions
let lockPromise = roomCreationLocks.get(roomId);
if (lockPromise) {
await lockPromise;
room = rooms.get(roomId);
}
if (!room) {
// Create and store lock before async boundary
let resolveLock;
lockPromise = new Promise(resolve => { resolveLock = resolve; });
roomCreationLocks.set(roomId, lockPromise);
try {
if (rooms.size >= MAX_ROOMS) {
log('ROOM', `Server at capacity: ${rooms.size}/${MAX_ROOMS} rooms — rejecting join`);
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
return;
}
const passwordHash = password ? await bcrypt.hash(password, 10) : null;
room = {
passwordHash,
peers: new Set(),
peerIds: new Map(),
peerData: new Map(), // socketId -> { peerId, tabTitle }
lastActivity: Date.now()
};
rooms.set(roomId, room);
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
} else {
const passwordHash = hashPassword(password);
room = {
passwordHash,
peers: new Set(),
peerIds: new Map(),
peerData: new Map(),
lastActivity: Date.now()
};
rooms.set(roomId, room);
createdByMe = true;
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
} finally {
roomCreationLocks.delete(roomId);
resolveLock();
}
}
}
if (!room) {
socket.emit(EVENTS.ERROR, { message: "Join error" });
return;
}
if (!createdByMe) {
if (room.passwordHash) {
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
if (!password || hashPassword(password) !== room.passwordHash) {
recordAuthFailure(ip, roomId);
log('AUTH', `Invalid password from ${ip} for room ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Invalid password" });
return;
}
}
if (room.peers.size >= MAX_PEERS_PER_ROOM) {
log('ROOM', `Room full (${room.peers.size}/${MAX_PEERS_PER_ROOM}): ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Room full" });
return;
}
// Peer Deduplication: Remove existing socket for the same peerId
const dedupeSids = [];
for (const [sid, data] of room.peerData.entries()) {
if (data.peerId === peerId && sid !== socket.id) {
const oldSocket = io.sockets.sockets.get(sid);
if (oldSocket) {
oldSocket.emit(EVENTS.ERROR, { message: 'Deduplication: Another session with this ID joined. Disconnecting...' });
oldSocket.leave(roomId);
oldSocket.disconnect(true);
log('DEDUPE', `Kicked old session for peer ${peerId}`);
}
removePeerFromRoom(sid, roomId, 'dedupe');
dedupeSids.push(sid);
}
}
for (const sid of dedupeSids) {
// Re-check: the socket might have been replaced by another concurrent join
const currentMapping = room.peerData.get(sid);
if (!currentMapping || currentMapping.peerId !== peerId) continue;
const oldSocket = io.sockets.sockets.get(sid);
if (oldSocket) {
oldSocket.emit(EVENTS.ERROR, { message: 'Deduplication: Another session with this ID joined. Disconnecting...' });
oldSocket.leave(roomId);
oldSocket.disconnect(true);
log('DEDUPE', `Kicked old session for peer ${peerId}`);
}
removePeerFromRoom(sid, roomId, 'dedupe');
}
}
socket.join(roomId);
@@ -309,12 +505,15 @@ io.on('connection', (socket) => {
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
socket.emit(EVENTS.ROOM_DATA, {
roomId,
peers: Array.from(room.peers).map(sid => room.peerData.get(sid))
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
activeLobby: room.activeLobby || null
});
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
} catch (err) {
log('ERROR', `Join error for ${socket.id}`, err);
socket.emit(EVENTS.ERROR, { message: "Join error" });
if (socket.connected) {
socket.emit(EVENTS.ERROR, { message: "Join error" });
}
}
});
@@ -323,23 +522,25 @@ io.on('connection', (socket) => {
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE,
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY,
EVENTS.EPISODE_LOBBY_CANCEL
];
relayEvents.forEach(eventName => {
socket.on(eventName, (data) => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
socket.disconnect(true);
return;
}
try {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
socket.disconnect(true);
return;
}
if (!data || typeof data !== 'object') return; // Prevent null/invalid payload crash
if (!data || typeof data !== 'object') return;
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const room = rooms.get(mapping.roomId);
if (room) {
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const room = rooms.get(mapping.roomId);
if (room) {
room.lastActivity = Date.now();
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
@@ -364,6 +565,7 @@ io.on('connection', (socket) => {
// --- S-3: Construct clean relay payload — never forward raw client data ---
const relayPayload = {
senderId: mapping.peerId,
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
currentTime: clampNum(data.currentTime, 0, 86400),
targetTime: clampNum(data.targetTime, 0, 86400),
playbackState: validState(data.playbackState),
@@ -372,7 +574,7 @@ io.on('connection', (socket) => {
mediaTitle: clamp(data.mediaTitle, 100),
volume: clampNum(data.volume, 0, 1),
muted: validBool(data.muted),
peerId: typeof data.peerId === 'string' ? data.peerId.substring(0, 16) : undefined,
peerId: mapping.peerId,
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
expectedTitle: clamp(data.expectedTitle, 100),
title: clamp(data.title, 100),
@@ -381,12 +583,39 @@ io.on('connection', (socket) => {
// Strip undefined keys for clean wire format
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
socket.to(mapping.roomId).emit(eventName, relayPayload);
// --- Side-effects: Server-side Episode Lobby Tracking ---
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
room.activeLobby = {
expectedTitle: relayPayload.expectedTitle,
initiatorPeerId: mapping.peerId,
readyPeers: [mapping.peerId]
};
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
room.activeLobby.readyPeers.push(mapping.peerId);
}
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) {
room.activeLobby = null;
}
}
}
} catch (err) {
log('ERROR', `Relay handler error for ${eventName}: ${err.message}`);
}
});
});
socket.on(EVENTS.GET_ROOMS, () => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket (GET_ROOMS): ${socket.id}`);
socket.disconnect(true);
return;
}
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
return;
}
const list = Array.from(rooms.entries()).map(([id, r]) => ({
id,
peerCount: r.peers.size,
@@ -403,28 +632,34 @@ io.on('connection', (socket) => {
}
});
socket.on(EVENTS.EVENT_ACK, (data) => {
if (!data || typeof data !== 'object') return;
if (typeof data.targetId !== 'string') return;
if (data.actionTimestamp !== undefined && (typeof data.actionTimestamp !== 'number' || !Number.isFinite(data.actionTimestamp))) return;
const senderMapping = socketToRoom.get(socket.id);
const targetSocketId = peerToSocket.get(data.targetId);
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
socket.on(EVENTS.EVENT_ACK, (data) => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket (ACK): ${socket.id}`);
socket.disconnect(true);
return;
}
if (!data || typeof data !== 'object') return;
if (typeof data.targetId !== 'string') return;
if (data.actionTimestamp !== undefined && (typeof data.actionTimestamp !== 'number' || !Number.isFinite(data.actionTimestamp))) return;
const senderMapping = socketToRoom.get(socket.id);
const targetSocketId = peerToSocket.get(data.targetId);
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
// Security: Only relay ACK if both peers are in the same room
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
senderId: senderMapping.peerId,
actionTimestamp: data.actionTimestamp
});
} else {
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
}
});
// Security: Only relay ACK if both peers are in the same room
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
senderId: senderMapping.peerId,
actionTimestamp: data.actionTimestamp
});
} else {
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
roomListCooldowns.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
if (mapping) {
// Socket is already disconnected — no need to call socket.leave().
@@ -436,12 +671,16 @@ io.on('connection', (socket) => {
});
// Active Room & Dead Peer Cleanup (Every 2m)
setInterval(() => {
const roomCleanupInterval = setInterval(() => {
const now = Date.now();
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
for (const [roomId, room] of rooms) {
// Snapshot room keys to avoid mutation during iteration
const roomIds = Array.from(rooms.keys());
for (const roomId of roomIds) {
const room = rooms.get(roomId);
if (!room) continue; // Room may have been deleted between snapshot and now
// 1. Prune dead peers
// Snapshot keys first — we must not mutate peerData while iterating it.
const staleSids = [];
@@ -453,14 +692,15 @@ setInterval(() => {
for (const sid of staleSids) {
// Gracefully evict the socket from the Socket.IO room if it is
// still technically connected (zombie with no heartbeat).
const deadSocket = io.sockets.sockets.get(sid);
const deadSocket = io.sockets?.sockets?.get(sid);
if (deadSocket) deadSocket.leave(roomId);
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
removePeerFromRoom(sid, roomId, 'reaper');
}
// 2. Prune empty or inactive rooms
if (room.peers.size === 0 || room.lastActivity < roomCutoff) {
const currentRoom = rooms.get(roomId);
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
rooms.delete(roomId);
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
@@ -468,9 +708,29 @@ setInterval(() => {
}
}, 2 * 60 * 1000);
httpServer.listen(PORT, () => {
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
});
export function startServer(port = PORT, host) {
if (httpServer.listening) return Promise.resolve(httpServer);
return new Promise((resolve, reject) => {
const onError = (err) => {
httpServer.off('listening', onListening);
reject(err);
};
const onListening = () => {
httpServer.off('error', onError);
const address = httpServer.address();
const actualPort = address && typeof address === 'object' ? address.port : port;
log('SERVER', `KoalaSync Relay running on port ${actualPort}`);
resolve(httpServer);
};
httpServer.once('error', onError);
httpServer.once('listening', onListening);
if (host) {
httpServer.listen(port, host);
} else {
httpServer.listen(port);
}
});
}
// --- M-4: Graceful Shutdown ---
function gracefulShutdown(signal) {
@@ -489,5 +749,44 @@ function gracefulShutdown(signal) {
}, 5000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
export async function stopServerForTests() {
clearInterval(authFailureCleanupInterval);
clearInterval(rateLimitCleanupInterval);
clearInterval(roomCleanupInterval);
rooms.clear();
socketToRoom.clear();
peerToSocket.clear();
roomCreationLocks.clear();
connectionCounts.clear();
failedAuthAttempts.clear();
eventCounts.clear();
healthCounts.clear();
adminMetricsAuthCounts.clear();
roomListCooldowns.clear();
healthResponseCache.clear();
io.removeAllListeners();
io.disconnectSockets(true);
if (!httpServer.listening) return;
await new Promise((resolve, reject) => {
httpServer.close((err) => err ? reject(err) : resolve());
});
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
startServer(PORT);
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
log('ERROR', `Unhandled rejection: ${reason}`);
process.exit(1);
});
}
+92
View File
@@ -0,0 +1,92 @@
import crypto from 'crypto';
export function checkCooldown(cooldowns, key, windowMs, now = Date.now()) {
const lastAllowedAt = cooldowns.get(key) || 0;
if (now - lastAllowedAt < windowMs) {
return false;
}
cooldowns.set(key, now);
return true;
}
export function getCachedPayload(cache, key, ttlMs, buildPayload, now = Date.now()) {
const cached = cache.get(key);
if (cached && now - cached.createdAt < ttlMs) {
return cached.payload;
}
const payload = buildPayload();
cache.set(key, { createdAt: now, payload });
return payload;
}
export function isAdminMetricsAuthorized(authHeader, adminToken) {
if (!adminToken || typeof adminToken !== 'string') return false;
if (!authHeader || typeof authHeader !== 'string') return false;
const prefix = 'Bearer ';
if (!authHeader.startsWith(prefix)) return false;
const provided = authHeader.slice(prefix.length);
if (!provided) return false;
const expectedBuffer = Buffer.from(adminToken);
const providedBuffer = Buffer.from(provided);
if (expectedBuffer.length !== providedBuffer.length) return false;
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
}
export function isAdminMetricsTokenStrong(adminToken, minLength = 32) {
return !adminToken || (typeof adminToken === 'string' && adminToken.length >= minLength);
}
export function buildHealthPayload({
rooms,
connections,
includeMetrics = false,
now = Date.now(),
uptime = 0,
memoryUsage = () => process.memoryUsage(),
rateLimitSizes = {}
}) {
const payload = {
status: 'ok',
uptime,
rooms: rooms.size,
connections,
timestamp: now
};
if (!includeMetrics) return payload;
const roomValues = Array.from(rooms.values());
const roomSizes = roomValues.map(room => room.peers?.size || 0);
const peers = roomSizes.reduce((sum, size) => sum + size, 0);
const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0;
const avgPeersPerRoom = roomSizes.length > 0
? Math.round((peers / roomSizes.length) * 100) / 100
: 0;
const mem = memoryUsage();
return {
...payload,
peers,
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
avgPeersPerRoom,
maxPeersInRoom,
rateLimitEntries: {
connections: rateLimitSizes.connections || 0,
events: rateLimitSizes.events || 0,
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
},
memory: {
rss: mem.rss,
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal
}
};
}
+14 -24
View File
@@ -9,7 +9,6 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"socket.io": "^4.8.3"
@@ -70,15 +69,6 @@
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -266,9 +256,9 @@
}
},
"node_modules/engine.io": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
"version": "6.6.8",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
@@ -280,7 +270,7 @@
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
"ws": "~8.20.1"
},
"engines": {
"node": ">=10.2.0"
@@ -749,9 +739,9 @@
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -951,13 +941,13 @@
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz",
"integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
"ws": "~8.20.1"
}
},
"node_modules/socket.io-parser": {
@@ -1079,9 +1069,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
-1
View File
@@ -11,7 +11,6 @@
"license": "ISC",
"type": "module",
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"socket.io": "^4.8.3"
+82 -3
View File
@@ -17,13 +17,37 @@ export const BLACKLIST_DOMAINS = [
'msn.com',
'baidu.com',
'yandex.ru',
'ecosia.org',
'startpage.com',
'search.brave.com',
'qwant.com',
'you.com',
'perplexity.ai',
'ask.com',
'search.yahoo.com',
'swisscows.ch',
'mojeek.com',
// Mail Providers
'mail.google.com',
'gmail.com',
'outlook.live.com',
'outlook.office.com',
'mail.yahoo.com',
'gmx.net',
'gmx.de',
'gmx.com',
'web.de',
'protonmail.com',
'proton.me',
't-online.de',
'posteo.de',
'mailbox.org',
'mail.de',
'zoho.com',
'fastmail.com',
'tutanota.com',
'mail.ru',
// Cloud Storage & Documents
'docs.google.com',
@@ -43,7 +67,6 @@ export const BLACKLIST_DOMAINS = [
// Productivity & Project Management
'atlassian.net',
'jira',
'trello.com',
'notion.so',
'monday.com',
@@ -61,9 +84,15 @@ export const BLACKLIST_DOMAINS = [
'instagram.com',
'reddit.com',
'quora.com',
'threads.net',
'bsky.app',
'mastodon.social',
'vk.com',
'weibo.com',
'9gag.com',
'imgur.com',
// E-Commerce
'amazon.',
'ebay.com',
'aliexpress.com',
'etsy.com',
@@ -78,6 +107,15 @@ export const BLACKLIST_DOMAINS = [
'myanimelist.net',
// Development & Utilities
'koalastuff.net',
'auth.koalastuff.net',
'blog.koalastuff.net',
'clicker.koalastuff.net',
'cookies.koalastuff.net',
'multibox.koalastuff.net',
'snippets.koalastuff.net',
'status.koalastuff.net',
'sync.koalastuff.net',
'timer.koalastuff.net',
'localhost',
'zoom.us',
@@ -85,10 +123,51 @@ export const BLACKLIST_DOMAINS = [
'meet.google.com',
'chrome.google.com',
// Music Streaming
'music.youtube.com',
'open.spotify.com',
'soundcloud.com',
'deezer.com',
'tidal.com',
// Knowledge & Blogs
'wikipedia.org',
'medium.com',
'dev.to',
'news.ycombinator.com',
// Design & Creative Tools
'figma.com',
'canva.com',
'miro.com',
// Online IDEs & Hosting
'vscode.dev',
'replit.com',
'codesandbox.io',
'vercel.com',
'netlify.com',
// Social & Image Sharing
'pinterest.com',
'tumblr.com',
// Language Learning
'duolingo.com',
'hellotalk.com',
// Google Utilities
'calendar.google.com',
'keep.google.com',
// Finance & Payments
'paypal.com',
'stripe.com',
// Games & Idle Sites
'milkywayidle.com',
'melvoridle.com',
'cookieclicker.',
'orteil.dashnet.org',
'clickerheroes.com',
'kongregate.com',
'armorgames.com',
+18 -3
View File
@@ -7,11 +7,25 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.3.1";
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
@@ -36,9 +50,10 @@ 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_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel" // Broadcast: cancel active lobby and resume
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby

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