Compare commits

...

92 Commits

Author SHA1 Message Date
KoalaDev aa84b63c77 docs: add v2.4.5 entry to CHANGELOG.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:38:36 +02:00
KoalaDev bd60a14754 feat(extension): send nothing to the server while alone in a room
When no other peer is present, heartbeats, force-sync, and episode auto-sync
are now fully suppressed (previously the keepAlive heartbeat, force-sync, and
episode lobby were still broadcast to an empty room):
- keepAlive heartbeat gated on a live peer count
- force-sync is a no-op when solo (no pause-then-timeout freeze, no traffic)
- episode auto-sync skips the lobby entirely and just plays the next episode

The solo state is recomputed live from the current peer list on every event
(never cached), so the instant another peer joins syncing resumes. On that
solo->not-solo transition the background also requests an immediate heartbeat
from the content script so the newcomer sees the current position without
waiting for the next interval.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:38:36 +02:00
KoalaDev a1bdcf4325 fix(extension): stop storing room/settings in chrome.storage.sync
Room ID, password, and username were resurrected from synced storage on a
fresh install (sync survives an uninstall in the user's Google account), which
made the extension silently auto-connect to a dead room and appear permanently
connected. getSettings() and all settings reads (popup, content, audio-options,
applyAudioSettingsToTab) are now local-only, and legacy keys are actively
purged from sync on install/update/startup. Only onboardingComplete and
dismissedHints remain in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:38:36 +02:00
KoalaDev b846803062 Potential fix for code scanning alert no. 14: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-23 00:56:28 +02:00
KoalaDev 4bc7ad365d docs: add v2.4.4 entry to CHANGELOG.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:49:42 +02:00
GitHub Action 68b2205b0d chore(release): update versions to v2.4.4 [skip ci] 2026-06-22 22:46:03 +00:00
KoalaDev b450845522 fix(extension): harden reconnect, ping, and offline-queue flush
Address client-side rate-limit edge cases that could trip the server's
per-socket event budget or per-IP connection limit:

- Pace the offline event-queue flush after (re)connect in batches
  (10 per 3s) instead of one synchronous burst, so a reconnect after a
  long outage no longer dumps the whole backlog and gets kicked.
- Wrap socket.send() in try/catch and re-queue on failure (race with a
  server-side disconnect) instead of losing the event.
- Ping liveness: tolerate one missed PONG; only force a reconnect after
  2 consecutive misses (~20s) instead of a single hard 5s timeout.
- Reconnect backoff: tune to ~8 attempts/60s (under the connection limit)
  and add +/-20% jitter to de-synchronize reconnect herds.

All magic numbers extracted into named constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:45:29 +02:00
KoalaDev 27023ea58e feat(server): raise event rate limit to 50 and name rate-limit constants
Raise the per-socket event budget from 30 to 50 per 10s to give legitimate
bursts (episode joins, force-sync, ACK flurries) more headroom. Extract the
previously inline connection/event/health/admin windows and limits into named
constants (CONNECTION_RATE_LIMIT, EVENT_RATE_LIMIT, *_WINDOW_MS). Tests now
derive their thresholds from the exported constants instead of hardcoding them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:45:19 +02:00
KoalaDev 1a7d23ce93 ci: add PR/push verification workflow and harden release
- Add .github/workflows/ci.yml running `npm run verify` (lint, tests,
  audits, builds) on every push to main and pull request, so regressions
  can't reach main or a release tag unchecked.
- release.yml: use `npm ci` instead of `npm install` for reproducible builds.
- package.json: add `test` script aliasing the verify suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:45:10 +02:00
KoalaDev d2c8ca1c1e Merge PR #11: extract tab management logic into independent module
3-way merge of Kaia-Alenia's tab-manager refactor (PR #11) onto current
main (v2.4.3). Only the tab-related service-worker listeners move out of
background.js into extension/modules/tab-manager.js via initTabManager({...})
dependency injection; all other recent main changes are preserved untouched.
Behavior-preserving; listeners still register synchronously at top level.

The unrelated website/assets/NewLogoIcon_64.webp change from the PR was
excluded to keep main clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:06:38 +02:00
Kaia-Alenia 1c404a7f11 refactor(extension): extract tab management logic into independent module 2026-06-19 14:11:47 -06:00
Timo e2bba04efd docs: update README language count to 15 (add zh, uk)
The localization section still listed 13 languages; Ukrainian (uk) and
Chinese Simplified (zh) were added in v2.4.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:49:34 +02:00
GitHub Action c7b29c277c chore(release): update versions to v2.4.3 [skip ci] 2026-06-19 14:39:06 +00:00
Timo e06965b95c docs: add v2.4.3 entry to CHANGELOG.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:38:49 +02:00
Timo 23d43741ce chore: replace Ko-Fi with Support links, drop uninstall token
Switch the Ko-Fi support links to https://support.koalastuff.net across the
README badge, the extension footer (popup.html/template.html, label
"Support KoalaSync") and remove the now-unused KOFI_URL constant. Stop
generating/sending the anonymous uninstall token: initUninstallURL() now
registers the feedback URL without the `t` parameter or storage write.
CHANGELOG updated to match.

Also guard the client CMD_ACK path: only ACK a command sender that is still
a known peer in the room, so we don't emit ACKs to peers that already left
(which the server now drops as absent-peer ACKs anyway).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:26:54 +02:00
Timo 027d2dbde5 fix(sync): coalesce play/pause bursts and quiet absent-peer ACK logs
Media players fire bursts of native play/pause events (source swaps, ABR,
ads, teardown). Each was relayed as a distinct command, spamming peers and
tripping the relay's event rate limit. Add leading+trailing coalescing in
the content script: emit the first event instantly (zero added latency) and,
on a 150ms window, collapse a burst to its final state. Echo-suppression and
seek-flush stay synchronous on event arrival; the trailing send is never
deduped against the leading one (a remote command may change shared state
mid-window — re-sending is the safe, idempotent choice).

Server: split the EVENT_ACK handler's logging so only a genuine different-room
ACK is logged as [SECURITY]; an ACK to a peer that already left is logged
quietly as [ACKDROP] (verbose-only), so real signals are no longer drowned out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:26:31 +02:00
Timo a0c16df664 feat(i18n): add Ukrainian & Chinese, fix zh/uk translation quality
Add two new languages (uk, zh / zh_CN) across the extension and website:
register them in SUPPORTED_LANGUAGES, the Chrome _locales map, the website
build/lang-init/app routing, hreflang/og/schema tags and language selectors.

Fix systematic machine-translation word-sense errors in the new zh/uk
locales (e.g. zh "Status"=地位→状态, "Leave Room"=留出空间→离开房间,
"Play"=玩→播放, "Seek"=寻找→跳转, audio Attack/Release/Knee; uk "Play"=Грати
→Відтворити, "Clear"=ЯСНО→Очистити, "Open"=ВІДЧИНЕНО→Відкрити, peers
"Однолітки"→"Учасники"), translate remaining English leftovers (Room ID,
Custom) and normalise terminology. All 15 locales pass test-locales.cjs.

Note: popup.html and website/template.html also carry the language-selector
additions here; their Ko-Fi→Support label text is part of the branding change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:26:20 +02:00
KoalaDev 8fd866f20a feat(website): add canonicals, hreflangs, meta descriptions, and localized schema descriptions 2026-06-19 12:10:08 +02:00
KoalaDev 8f72c238a2 docs: add v2.4.2 entry to CHANGELOG.md 2026-06-19 11:58:06 +02:00
GitHub Action 24ad7c3732 chore(release): update versions to v2.4.2 [skip ci] 2026-06-19 09:54:42 +00:00
KoalaDev 32fd677a31 feat: optimize extension uninstall URL registration with anonymous token and startup hardening 2026-06-19 11:54:07 +02:00
KoalaDev fb22700268 docs: revert completed milestones in ROADMAP.md 2026-06-19 11:39:50 +02:00
KoalaDev 59b675ce17 docs: audit and update script references, supported languages, and completed milestones 2026-06-19 11:35:49 +02:00
KoalaDev 8a9293280b chore: harden version substitution in release workflow and fix supply chain security version in README 2026-06-19 11:12:48 +02:00
KoalaDev 450f715874 ci(release): update actions to Node 24 versions & optimize workflow
- Bump all actions off the deprecated Node 20 runtime: checkout v4->v7,
  upload-artifact v4->v7, setup-node added@v6, action-gh-release v1->v3,
  docker build-push v5->v7, login v3->v4, metadata v5->v6, buildx v3->v4
  (verified latest majors via GitHub API; no breaking input/output changes)
- Add npm dependency caching via setup-node (cache: npm)
- Add GHA layer caching for the multi-arch Docker build (cache-from/to gha)
- Add concurrency group so a release run is never interrupted, only dedupes
  re-pushes of the same tag
- Tighten release-server permissions to contents: read (it never writes to the repo)

Version-injection and commit-back logic unchanged. First exercised on the next v* tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:59:30 +02:00
GitHub Action b4c00f36a0 chore(release): update versions to v2.4.1 [skip ci] 2026-06-19 08:50:27 +00:00
KoalaDev 36f494fa55 feat(extension): one-click invite button in empty peer list + v2.4.1 changelog
- "No peers yet" state now shows a localized "Copy invite link" button (reuses BTN_COPY_INVITE, present in all 13 locales) so users can share without hunting for the field
- Add v2.4.1 changelog entry covering the onboarding tour improvements, empty-state action, and the mobile landing-page fixes from this session

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:50:04 +02:00
KoalaDev b5b3885d19 feat(extension): finish onboarding tour with closing step & cleaner welcome
- Add the previously-orphaned 5th onboarding step (ONBOARDING_5) so the tour ends on a closing card instead of stopping at the username field
- Make step 1 a centered welcome card instead of spotlighting the logo; guard querySelector for target-less steps
- Fix stale static placeholders in popup.html (Step 1 of 3 -> 5, progress 33% -> 20%)

Verified ONBOARDING_5/DONE exist across all 13 locales; rendered with the longest (German) copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:40:40 +02:00
KoalaDev 373f2d127a fix(website): mobile comparison cards, hamburger nav, hero symmetry & reveal fallback
- Comparison table: stack into per-feature cards on phones (no more horizontal scroll); bring feature descriptions back
- Re-enable mobile hamburger menu (was hidden via display:none !important); keep logo/language/button uncrowded down to ~320px
- Fix hero asymmetry: extension mockup forced the grid column wider than the container; make it width:100%/max-width:320px and use minmax(0,1fr)
- Add IntersectionObserver guards + <noscript> fallback so reveal-animated content never stays hidden

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:27:07 +02:00
KoalaDev 3ebe80aab2 refactor: replace dynamic GitHub API badges with static shields.io badges 2026-06-17 06:39:42 +02:00
Timo e7b59d61ac docs: update all docs for lazy-connect, refactor, and .cjs rename
- ARCHITECTURE.md: startup -> lazy connect, reconnect when in room
- HOW_IT_WORKS.md: on-demand connection, heartbeat while connected
- PRIVACY.md: clarify alarms only during active sessions
- SYNC_GUIDE.md: build-extension.cjs + episode-utils injection
- StoreDescription.md: on-demand relay, no persistent connection
- README.md + extension/README.md: npm run build:extension
- extension/README.md: replace Dual Heartbeat with On-Demand Connection
- website/privacy.html: add lazy-connect privacy note
2026-06-16 14:17:19 +02:00
GitHub Action 71f7ac425a chore(release): update versions to v2.4.0 [skip ci] 2026-06-16 12:00:03 +00:00
Timo 394b9ba474 Merge branch 'refactor/split-monoliths' 2026-06-16 13:58:32 +02:00
Timo 526736b045 fix(ci): update website build path to .cjs after rename 2026-06-16 13:54:57 +02:00
Timo 86112057b4 test: add names generator unit tests (getAvatarForName + generateUsername)
Tests: 20 deterministic avatar assertions (exact match, case insensitive,
longest-match-wins, ZWJ sequences, fallback), 30 randomized username format
checks, and validation that every noun in the list has an emoji mapping.
2026-06-16 13:53:19 +02:00
Timo c56fcfd281 test(server): add 13 WebSocket integration tests covering all server features
Tests: connect, room create/join, password auth, play/pause/seek relay,
force_sync, event_ack, episode_lobby, peer leave, ping/pong, room list,
protocol version check, deduplication, health HTTP endpoint.
All 13 tests pass as part of npm run verify.
2026-06-16 13:48:21 +02:00
Timo 5c43fc6236 docs: add v2.4.0 changelog entries for lazy-connect and refactor 2026-06-16 13:23:53 +02:00
Timo 101761e984 chore: add type:module, fix npm audit, rename CJS files, document module structure
- package.json: add type: module to silence ESM warnings
- server: npm audit fix (0 vulnerabilities, ws vuln resolved)
- Rename 4 CJS files to .cjs: build-extension, test-content-video-finder,
  test-locales, website/build
- Update eslint.config.mjs for .cjs glob patterns
- extension/README.md + server/README.md: document module structure
2026-06-16 13:22:45 +02:00
Timo 3bc68a5713 test: add unit tests for rate-limiter and episode-utils
- test-rate-limiter.mjs: 12 test groups covering all rate-limit
  functions, Maps, cleanup, and double-start guard
- test-episode-utils.mjs: 30+ assertions covering SxxExx patterns,
  6 separator types (dash/dot/slash/colon/comma/space), German/English,
  edge cases (null/undefined/empty), and sameEpisode matching logic
- verify-release.mjs: added 2 test suites + 1 syntax check to pipeline
2026-06-16 13:15:25 +02:00
Timo 6c96dd6344 chore(refactor): remove orphaned comment, add double-start guard to rate-limiter 2026-06-16 13:10:37 +02:00
Timo d7bb8dc97c refactor(extension): extract episode-utils from background.js + content.js
Move extractEpisodeId() and sameEpisode() to shared episode-utils.js.
background.js imports via ES module. content.js (IIFE) receives injected
copy via build-extension.js marker replacement, eliminating duplication.
2026-06-16 12:48:11 +02:00
Timo d38a840114 refactor(server): extract rate-limiter module from index.js
Move 6 rate-limit functions, all rate-limit Maps, cleanup intervals,
and denial counter to server/rate-limiter.js. 149 lines extracted.
Index.js re-exports what tests and ops.js need.
npm run verify passes.
2026-06-16 12:44:43 +02:00
Timo e7d2d76ba3 fix(eslint): add missing browser globals history and location 2026-06-16 10:50:40 +02:00
Timo 5277cd0f21 fix(eslint): add missing browser globals history and location 2026-06-16 10:50:32 +02:00
Timo 4d7cbfe347 Merge branch 'feature/lazy-connect' 2026-06-16 10:47:46 +02:00
Timo 91aa76e842 fix: revert premature isConnecting reset + remove popup disconnected flicker
- background.js: remove isConnecting=false at line 608 (was set before
  WebSocket handshake completed, defeating the guard for the entire
  CONNECTING phase). isConnecting is already properly reset in onclose,
  onopen/40 handler, catch block, and socket guard path.
- background.js: restore isConnecting=false in forceDisconnect() so
  subsequent connect() calls can proceed after intentional disconnect.
- popup.js: remove hardcoded applyConnectionStatus('disconnected') that
  caused a guaranteed red flicker on every popup open. Status is now
  set only by the async GET_STATUS response.
2026-06-16 10:44:57 +02:00
Timo 5f0a189451 fix(popup): trigger CONNECT when popup opens with saved roomId
If user has a room configured but the background is not connected
(e.g. service worker was killed, startup race, or stale state), the
popup now sends CONNECT on open. Previously it only called GET_STATUS
and showed 'disconnected' with 0 logs — the user had no way to know
whether the extension was working.
2026-06-16 10:35:13 +02:00
KoalaDev 312d4f2cf7 chore: update sitemap lastmod dates, automate in release CI
- Update all sitemap <lastmod> entries from 2026-06-09 to 2026-06-16.
- Add sed step in release workflow to auto-update sitemap dates on every
  tagged release, so the sitemap never goes stale.
2026-06-16 06:11:13 +02:00
KoalaDev 884feb982d fix(website): smooth anchor scrolling, language-switch scroll position, lang select UX
- Replace JS scrollIntoView handler with CSS scroll-behavior:smooth for
  native anchor navigation. Fixes #hash-based scroll position surviving
  language switches since the hash stays in the URL.
- Auto-update URL hash via IntersectionObserver as user scrolls through
  sections, so manual scrolling also sets the anchor for language switch.
- Preserve window.location.hash across all language-switch navigations.
- Fix Chrome double-arrow on language select by limiting appearance:base-select
  to ::picker(select) only, keeping the custom SVG chevron.
- Make chevron absolutely positioned over the select with pointer-events:none
  so clicks anywhere on the container open the dropdown.
- Remove max-width constraint on language select so longer names fit.
- Add scroll-margin-top to section[id] and header[id] so anchored sections
  clear the fixed navbar (100px offset).
- Add header[id] to scroll-margin-top for keyboard skip-link accessibility.

Thanks to Taradal from Reddit
2026-06-16 06:05:49 +02:00
KoalaDev b2da17ab62 fix(lazy-connect): harden connection lifecycle against race conditions and edge cases
- Prevent concurrent connect() by removing isConnecting reset from
  forceDisconnect(); the guard in connect() now reliably blocks
  re-entry since no external caller can defeat it.
- Reset isConnecting at end of connect() so subsequent calls can
  proceed without waiting for the async '40' handler.
- Clear connectIntent and cancel reconnect timer on server ERROR
  when no currentRoom exists, preventing infinite reconnect loops
  after failed join attempts (e.g. wrong password via invite link).
- Move connectIntent assignment after !roomId guard in
  WEB_JOIN_REQUEST to avoid clobbering existing intent on invalid
  input.
- Broadcast JOIN_STATUS failure to popup and bridge tabs when
  WEB_JOIN_REQUEST receives an invalid room ID, so the website
  join page receives feedback instead of hanging forever.
- Clear joinBtnTimeout on CONNECTION_STATUS connected/disconnected
  to avoid stale timeout error messages in the popup.
2026-06-16 05:39:17 +02:00
KoalaDev b518685e2c merge: bring main (v2.3.2) changes into lazy-connect 2026-06-16 04:59:13 +02:00
GitHub Action b7a7a14a35 chore(release): update versions to v2.3.2 [skip ci] 2026-06-16 02:29:41 +00:00
KoalaDev 9ee22d985f docs: remove transient _locales fix from changelog (never released) 2026-06-16 04:28:30 +02:00
KoalaDev 2070e701de docs: add v2.3.2 changelog entry (translations, locale fixes, timing leak) 2026-06-16 04:26:07 +02:00
KoalaDev dd8eefe3f9 fix(security): actually prevent timing leak — eagerly evaluate timingSafeEqual
The previous fix had a subtle bug: JavaScript && short-circuits,
so crypto.timingSafeEqual() was skipped when buffer lengths differed
(sameLength was false). The dummy buffer was allocated but never
compared, leaving the original length-based timing leak intact.

Now timingSafeEqual is eagerly assigned to a const before the &&
guard, guaranteeing it runs in constant time on every auth attempt
regardless of whether the length guess was correct.
2026-06-16 04:23:20 +02:00
KoalaDev cc97e0d371 fix(security): prevent admin token length leak via timing side-channel
isAdminMetricsAuthorized() returned early when buffer lengths differed,
allowing an attacker to discover the token length by measuring response
time. Now always calls crypto.timingSafeEqual (using a zeroed dummy
buffer on length mismatch) so every auth attempt takes constant time
regardless of whether the length guess was correct.

Reported-by: Kaia-Alenia
2026-06-16 04:21:15 +02:00
KoalaDev 7571f1986d fix: restore _locales key names, remove orphan pt/ dir, fix locale typos
- _locales/es, pt_BR: renamed appDescription → appDesc (Chrome matches by key name)
- _locales/es, pt_BR: removed extra key contextMenu_syncVideo not in baseline
- _locales/pt_PT: merged Kaia's improved translation, deleted orphan pt/ duplicate
- locales/ko: fixed BTN_REFRESH (Refreschi → 새로고침) and STATUS_CONNECTING corrupted char
- locales/ja: fixed Korean character (의 → の) in LABEL_PUBLIC_ROOMS_TOOLTIP
- locales/nl: fixed typo cmmuniceren → communiceren
2026-06-16 04:19:53 +02:00
KoalaDev bb1a88c085 Merge pull request #8 from Kaia-Alenia/feat/complete-spanish-translation
i18n: complete and refined Spanish translation for extension and website
2026-06-16 03:43:54 +02:00
Kaia-Alenia 07863bdad6 docs: credit Alenia Studios for PT and PT-BR translations 2026-06-15 13:10:55 -06:00
Kaia-Alenia 5e6306432f i18n: complete and refined Portuguese (PT and BR) translations 2026-06-15 13:10:35 -06:00
Kaia-Alenia fa5dfc1cf1 docs: credit Alenia Studios for ES and IT translations 2026-06-15 11:19:52 -06:00
Kaia-Alenia b88b8bbe47 i18n: complete and refined Italian translation 2026-06-15 11:18:46 -06:00
Kaia-Alenia 8aab8ce2f5 docs: shorten Spanish translation context 2026-06-15 11:11:23 -06:00
Kaia-Alenia 95b4608236 docs: update Spanish translation status to 100% manually verified 2026-06-15 11:07:35 -06:00
Kaia-Alenia 6d42ee7d4c i18n: complete and refined Spanish translation for extension and website 2026-06-15 11:03:57 -06:00
Timo 3f8cf33dc9 fix: WEB_JOIN_REQUEST sendResponse leak + popup validation state cleanup
- WEB_JOIN_REQUEST: add missing sendResponse when already in target room
- popup join: reset isProcessingConnection + clear timeout on validation failures
2026-06-15 14:40:15 +02:00
Timo f12bb0a532 fix(extension): reset reconnectAttempts on leave to prevent stale reconnecting status
After LEAVE_ROOM or idle-leave, reconnectAttempts was not reset.
GET_STATUS would return 'reconnecting' instead of 'disconnected',
showing wrong status in popup and leaving Retry button visible.
2026-06-15 14:22:17 +02:00
Timo 1685b6a327 fix(popup): join timeout checks connection status instead of blind 15s timer
Lazy-connect introduces a new state where the user clicks 'Raum erstellen'
and the WebSocket takes 1-2s to establish (previously always pre-connected).
The old 15s blind timeout would re-enable the button while background was
still connecting, causing silent no-op clicks. Now polls GET_STATUS and
extends the window if still connecting.
2026-06-15 14:19:18 +02:00
Timo ec8f56a85d feat(extension): lazy-connect — only maintain WebSocket when actively in room
- Add connectIntent flag to gate all reconnect attempts
- Only auto-connect on startup if roomId exists in storage
- Close socket + stop reconnect after leaveRoom or idleLeave
- Preserve existing behavior 1:1 when actively in a room
- Guard force-sync state and peer list clearing during transient disconnects
- Guard WEB_JOIN_REQUEST against empty sanitized roomId
2026-06-15 14:08:29 +02:00
Timo 38dc923a7c Merge main (v2.3.1) into feature/lazy-connect 2026-06-15 13:30:57 +02:00
KoalaDev 4ca1ef22d2 Revise CHANGELOG for v2.3.1 updates
Updated changelog for version 2.3.1 with fixes and changes.
2026-06-15 13:17:10 +02:00
Timo 27e57862c0 docs: shorten v2.3.1 changelog entry 2026-06-15 13:15:01 +02:00
GitHub Action c391068706 chore(release): update versions to v2.3.1 [skip ci] 2026-06-15 11:14:23 +00:00
Timo d76e9195c4 fix(server): race condition on concurrent peer joins, crash-safe teardown, smart unhandled rejection handling
- Add per-peerId serialization lock (peerJoinLocks) to prevent concurrent dedupe races

- Wrap removePeerFromRoom calls in disconnect/leave/reaper with try/catch

- Replace immediate process.exit on unhandledRejection with rate-limited smart exit

- Optimize buildHealthPayload from 3-pass array ops to single for-of loop

- Reset rateLimitDenied counters in stopServerForTests

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 02:07:21 +00:00
GitHub Action 9f553b4f8f chore(release): update versions to v2.2.4 [skip ci] 2026-06-09 23:42:27 +00:00
97 changed files with 4243 additions and 2040 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ Closes #
- [ ] 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
- [ ] Protocol changes: I ran `node scripts/build-extension.cjs` and updated relevant docs
- [ ] No new warnings, secrets, or hardcoded credentials introduced
### Additional Context
+46
View File
@@ -0,0 +1,46 @@
name: CI
# Runs the full verification suite (lint, unit/integration tests, production
# audits, extension + website build) on every push to main and every PR, so a
# regression can never reach main or a release tag unchecked.
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
# Cancel superseded runs on the same ref to save CI minutes. Unlike the release
# workflow, an interrupted CI run has no side effects.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: |
package-lock.json
server/package-lock.json
- name: Install root dependencies
run: npm ci
# The server test suite (test-server-ws/routes/ops) imports express,
# socket.io, and dotenv from server/node_modules, so install them too.
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Run verification suite
run: npm run verify
+35 -15
View File
@@ -5,23 +5,29 @@ on:
tags:
- 'v*'
# A release run must never be interrupted (it commits back to main and publishes
# artifacts). Only dedupe accidental re-pushes of the same tag.
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
release-server:
runs-on: ubuntu-latest
permissions:
contents: write
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -29,7 +35,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -38,7 +44,7 @@ jobs:
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: server/Dockerfile
@@ -46,6 +52,9 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Reuse layers across releases to speed up the multi-arch build.
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate artifact attestation
uses: actions/attest@v4
@@ -62,7 +71,13 @@ jobs:
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Extract version from tag
id: version
@@ -79,7 +94,7 @@ jobs:
echo " ✓ manifest.base.json -> $VERSION"
# 2. shared/constants.js — APP_VERSION
sed -i "s/export const APP_VERSION = '.*'/export const APP_VERSION = '$VERSION'/" shared/constants.js
sed -i "s/export const APP_VERSION = [\"'].*[\"']/export const APP_VERSION = \"$VERSION\"/" shared/constants.js
echo " ✓ shared/constants.js -> $VERSION"
# 3. package.json
@@ -94,17 +109,22 @@ jobs:
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
echo " ✓ website/template.html -> softwareVersion $VERSION"
# 6. README.md — version badge
sed -i "s/v[0-9]\+\.[0-9]\+\.[0-9]\+/v$VERSION/g" README.md
# 6. README.md — version badge & banner
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
echo " ✓ README.md -> v$VERSION"
# 7. website/sitemap.xml — lastmod dates
sed -i "s/<lastmod>[0-9-]*<\/lastmod>/<lastmod>$(date +%Y-%m-%d)<\/lastmod>/g" website/sitemap.xml
echo " ✓ website/sitemap.xml -> lastmod $(date +%Y-%m-%d)"
echo "Version injection complete."
- name: Commit and push version updates back to main
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md website/sitemap.xml
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
git push origin HEAD:main
env:
@@ -112,7 +132,7 @@ jobs:
- name: Build Extensions
run: |
npm install
npm ci
npm run build:extension
- name: Generate artifact attestation for extensions
@@ -121,17 +141,17 @@ jobs:
subject-path: dist/koalasync-*.zip
- name: Build Website
run: node website/build.js
run: node website/build.cjs
- name: Upload Website Artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: website-www
path: website/www/
if-no-files-found: error
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v3
with:
files: |
dist/koalasync-chrome.zip
+30 -18
View File
@@ -12,7 +12,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|------|-------------|
| **Bug Reports** | Found a bug? Open an issue with repro steps (see template below). |
| **Code** | Fix bugs, add features, or improve the extension / server / website. |
| **Translations** | Help localize the extension and website into more languages. See [TRANSLATION.md](website/TRANSLATION.md). |
| **Translations** | Help localize the extension and website into more languages. See [TRANSLATION.md](docs/TRANSLATION.md). |
| **Documentation** | Improve docs, fix typos, or add missing examples. |
| **Security** | Found a vulnerability? See [SECURITY.md](SECURITY.md) — do NOT open a public issue. |
@@ -31,7 +31,7 @@ Please note that by participating in this project, you agree to abide by our [Co
git clone https://github.com/Shik3i/KoalaSync.git
cd KoalaSync
npm install
node scripts/build-extension.js
node scripts/build-extension.cjs
```
---
@@ -62,7 +62,7 @@ node scripts/build-extension.js
### Website
```bash
node website/build.js # Compile static site → www/
node website/build.cjs # Compile static site → www/
python3 -m http.server 8080 -d website/www # Serve locally
```
@@ -77,7 +77,7 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
> [!IMPORTANT]
> After modifying `shared/constants.js`, you **must** run the build script to sync changes to the extension:
> ```bash
> node scripts/build-extension.js
> node scripts/build-extension.cjs
> ```
> This automatically injects constants into `content.js` and regenerates browser bundles in `dist/`.
@@ -100,15 +100,23 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
---
## Pull Request Process
## Open Source Workflow & Pull Requests
1. **Branch** from `main` for your feature or fix.
2. **Test locally** on both Chrome and Firefox.
3. **Website changes**: Run `node website/build.js` and verify the compiled output in `www/`.
4. **Lint**: Ensure `npm run lint` passes with zero errors and zero warnings.
5. **Syntax**: Run `node -c` on every modified `.js` file.
6. **Protocol changes**: Update relevant documentation in `docs/`.
7. **Submit your PR** with a clear description and linked issue (if applicable).
If you are new to open-source contributions, follow these steps to propose your changes:
1. **Fork the Repository**: Click the "Fork" button at the top right of this repository to create your own copy of KoalaSync.
2. **Clone your Fork**: `git clone https://github.com/YOUR-USERNAME/KoalaSync.git`
3. **Create a Branch**: `git checkout -b my-new-feature` (e.g. `feature/dark-mode` or `fix/translation-de`)
4. **Make your Changes**: Edit the files, then verify them locally.
- *Extension/Server changes*: Test on Chrome/Firefox and check `npm run lint`.
- *Website/Translation changes*: Run `node website/build.cjs` and check the output in `www/`.
5. **Commit and Push**: `git commit -m "Add my feature"` and `git push origin my-new-feature`
6. **Open a Pull Request (PR)**: Go to the original KoalaSync repository on GitHub and click "New Pull Request".
### PR Code Requirements
- **Lint**: Ensure `npm run lint` passes with zero errors and warnings.
- **Syntax**: Run `node -c` on every modified `.js` file.
- **Protocol changes**: Update relevant documentation in `docs/`.
---
@@ -140,14 +148,18 @@ If you cannot access the Status tab, include as much of the following manually:
---
## Translation Contributions
## Translation Contributions (Translators Welcome!)
KoalaSync supports 6 languages: English, German, French, Spanish, Portuguese (Brazilian), and Russian.
We welcome native speakers to help translate KoalaSync! You **do not** need deep programming knowledge to contribute translations.
To add or improve translations:
1. Edit the locale files in `website/locales/` (for the website).
2. For extension translations, see [TRANSLATION.md](website/TRANSLATION.md).
3. Run `node website/build.js` to regenerate the static site.
KoalaSync supports multiple languages. To add or improve translations:
1. Read the **[Translation Guide](docs/TRANSLATION.md)** first. It explains how our localization system works.
2. Edit the `.json` files in `website/locales/` (for the website) and `extension/locales/` (for the extension).
3. Test your translations locally by running:
- `node scripts/test-locales.cjs` (for extension)
- `node scripts/test-website-locales.mjs` (for website)
- `node website/build.cjs` (to build the site)
4. Follow the **Open Source Workflow** above (Fork -> Branch -> Edit -> PR) to submit your translations.
---
+10 -9
View File
@@ -6,15 +6,15 @@
<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>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.4.4-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></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" 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>
<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.3 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.4.4 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
@@ -59,9 +59,9 @@ The easiest and safest way to install KoalaSync is directly through the official
### 🌐 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`).
- **Available Languages**: Support is included for 15 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`), Chinese (Simplified) (`zh`), Ukrainian (`uk`), 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.
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](docs/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
---
@@ -81,7 +81,7 @@ Both the official KoalaSync website and the **v2.0 Browser Extension** feature f
To build the extension from source and synchronize protocol constants:
```bash
npm install
node scripts/build-extension.js
npm run build:extension
```
The compiled artifacts will be available in the `dist/` directory.
@@ -103,7 +103,7 @@ To connect your extension to a self-hosted server, open the popup → **Room** t
To verify your relay is reachable from outside, visit `https://your-domain.com` in a browser — it should return `{"status":"online","service":"KoalaSync Relay"}`.
#### Supply Chain Security (v2.2.3+)
#### Supply Chain Security (v2.2.2+)
All official release artifacts (Docker images and extension binaries) are published with signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) to prove they were built from this repository's source code.
@@ -125,19 +125,20 @@ gh attestation verify dist/koalasync-chrome.zip \
- **[CHANGELOG.md](docs/CHANGELOG.md)**: Full version history for the extension and relay server.
- **[TESTED_SERVICES.md](docs/TESTED_SERVICES.md)**: Detailed compatibility matrix of tested streaming platforms and known limitations.
- **[TRANSLATION.md](website/TRANSLATION.md)**: Translation and localization guide for contributors.
- **[TRANSLATION.md](docs/TRANSLATION.md)**: Translation and localization guide for contributors.
- **[PRIVACY.md](docs/PRIVACY.md)**: Data Handling and Privacy Policy.
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: How to help make KoalaSync better.
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
- **[ROADMAP.md](docs/ROADMAP.md)**: Planned features, backlog, and rejected ideas.
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
---
<div align="center">
<sub><a href="https://ko-fi.com/koaladev"><img src="https://img.shields.io/badge/Ko--Fi-Support-FF5E5B?logo=ko-fi&logoColor=white" alt="Ko-Fi"></a></sub>
<sub><a href="https://support.koalastuff.net"><img src="https://img.shields.io/badge/Support-KoalaSync-FF5E5B" alt="Support KoalaSync"></a></sub>
<sub><a href="https://gitgem.org/github/Shik3i/KoalaSync"><img src="https://gitgem.org/api/badge/github/Shik3i/KoalaSync.svg" alt="GitGem Badge" /></a></sub>
<sub>Built with ❤️ by <a href="https://github.com/Shik3i">Shik3i</a>. KoalaSync is Open Source under the <a href="LICENSE">MIT License</a>.</sub>
+1 -1
View File
@@ -40,7 +40,7 @@ KoalaSync is built for private watch parties without unnecessary data collection
⚙️ UNDER THE HOOD
KoalaSync is lightweight, transparent, and built with privacy in mind.
Real-Time Relay: Playback state is synchronized through a custom WebSocket-based relay server for fast room updates.
On-Demand Relay: Playback state is synchronized through a custom WebSocket-based relay server. No persistent connection — the relay is only active while you're in a room. No background traffic, no idle connections.
• No Media Streaming: KoalaSync does not stream, proxy, upload, download, or redistribute any video content. Everyone watches from their own browser on the original website.
• Temporary Room State Only: The relay server only coordinates room state such as play, pause, seek position, active target, nickname, and readiness status.
• Docker Self-Hosting: The relay server can be self-hosted with Docker if you prefer to run your own private instance.
+8 -8
View File
@@ -21,14 +21,14 @@ 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.
- **`build.cjs`**: 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.cjs` 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`).
- `scripts/`: Development utilities (e.g., `build-extension.cjs`).
- `docker-compose.yml`: Root-level orchestration for the relay server.
> [!IMPORTANT]
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `node scripts/build-extension.js`.
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `node scripts/build-extension.cjs`.
> - **Extension Modules** (`background.js`, `popup.js`) import directly from `./shared/constants.js`.
> - **Content Scripts** (`content.js`) use a **marker-injected synchronous copy** of the constants. The build script automatically replaces the marked blocks — no manual mirroring needed.
@@ -41,7 +41,7 @@ Before touching any code, you MUST read the following documents in order:
## 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.cjs`) 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
@@ -137,18 +137,18 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
### Adding a Protocol Event
1. Add the event name to `shared/constants.js`.
2. Run the build script (`node scripts/build-extension.js`).
2. Run the build script (`node scripts/build-extension.cjs`).
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.
2. Run the compiler: `node website/build.cjs`. 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`.
1. Run the build script: `node scripts/build-extension.cjs`.
2. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (or `dist/firefox/` in Firefox).
3. Start the server from the root: `docker-compose up --build`.
4. Use **different browser profiles** or vendors to test multi-peer logic.
+6 -5
View File
@@ -2,9 +2,10 @@
This document describes the communication flows and internal logic of the KoalaSync system.
## 1. Extension Startup & Connection
- **Initialization**: On startup, `background.js` reads settings (Server URL, Username, Last Room) from `chrome.storage.sync`.
- **WebSocket Handshake**:
## 1. Extension Connection (Lazy Connect)
- **Initialization**: On startup, `background.js` reads settings (Server URL, Username, Last Room) from `chrome.storage.sync`. No WebSocket connection is established at this point.
- **On-Demand Connection**: The extension only connects when needed — either the user opens the popup with saved room credentials, or when actively in a room. When not in a room, no connection exists. This improves privacy (IP not exposed while idle) and reduces battery/network usage.
- **WebSocket Handshake (when connecting)**:
1. Background creates a `new WebSocket` to `/socket.io/?EIO=4&transport=websocket&version=1.0.0`.
2. Server performs security checks:
- **IP Rate Limit**: Checks if the IP has exceeded connection limits.
@@ -45,7 +46,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.
- **Reconnect Strategy (while in room)**: 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. When not in a room, no reconnection occurs.
> [!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.
@@ -70,5 +71,5 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
To maintain a "Single Source of Truth" across the server and extension without using a bundler:
- **Relay Server & Extension Modules**: `background.js` and `popup.js` import constants directly from `shared/constants.js`.
- **Content Scripts**: To ensure zero-latency execution, `content.js` uses a synchronized copy of `EVENTS` and constants.
- **Automation**: The `node scripts/build-extension.js` script automatically injects these constants into `content.js` during the build process, eliminating the risk of manual mirror mismatch.
- **Automation**: The `npm run build:extension` script automatically injects `EVENTS`, `HEARTBEAT_INTERVAL`, and `episode-utils.js` functions into `content.js` during the build process, eliminating the risk of manual mirror mismatch.
- **Verification**: Any protocol change is automatically propagated across the stack by running the build script.
+116 -1
View File
@@ -4,6 +4,121 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.4.5] — 2026-06-23
### Fixed
- **Room and settings are no longer stored in `chrome.storage.sync`** — Room ID, password, and username were being resurrected from synced storage on a fresh install (sync survives an uninstall in the user's Google account), which made the extension silently auto-connect to a dead room and appear permanently connected. `getSettings()` and all settings reads are now local-only, and legacy keys are actively purged from sync on install/update/startup. Only `onboardingComplete` and `dismissedHints` remain in sync.
- **No server traffic while alone in a room** — When you are the only peer, heartbeats, force-sync, and episode auto-sync are now fully suppressed (previously the keepAlive heartbeat, force-sync, and episode lobby were still broadcast to an empty room). The solo state is re-evaluated live on every event — never cached — so the instant another peer joins, syncing resumes immediately, including an instant state push so the newcomer sees your current position without waiting for the next heartbeat.
## [v2.4.4] — 2026-06-23
### Changed
- **Server: Event rate limit raised 30 → 50 per 10s**, and all connection/event/health rate-limit thresholds and windows extracted into named constants.
- **Extension: Reconnect backoff tuned and jittered** — capped at ~8 attempts/60s (under the per-IP connection limit) with ±20% jitter to de-synchronize reconnect herds after a server blip.
- **CI: Added a verification workflow** running lint, tests, audits, and builds on every push/PR; the release build now uses `npm ci`.
### Fixed
- **Extension: Offline event-queue flush is now paced** (small batches instead of one synchronous burst) so a reconnect after a long outage no longer trips the server event limit and gets disconnected on rejoin.
- **Extension: Ping liveness tolerates one missed PONG** — a reconnect is forced only after 2 consecutive misses (~20s) instead of a single 5s timeout, avoiding spurious drops under transient load.
- **Extension: `socket.send()` failures are caught and re-queued** instead of losing the event on a disconnect race.
## [v2.4.3] — 2026-06-19
### Added
- **Two new languages: Ukrainian (`uk`) and Chinese (`zh`, Simplified)** — added across the extension (UI strings + Chrome `_locales`) and the website (localized pages, hreflang/Open Graph/schema tags, language selector), bringing the total to 15 languages.
### Changed
- **Play/pause sync coalescing** — The content script now collapses rapid bursts of native play/pause events (source swaps, ABR/quality switches, ad transitions, page teardown) into a single relayed command: the first event is sent instantly and a short 150ms window absorbs the rest. This cuts redundant relay traffic and stops bursts from tripping the server's per-socket event rate limit.
### Fixed
- **zh/uk translation quality** — Corrected systematic machine-translation word-sense errors in the two new locales (e.g. "Play", "Status", "Leave Room", "Clear", "Open", "peers", and audio compressor terms) and translated the remaining English leftovers.
- **Relay logging** — An `EVENT_ACK` aimed at a peer that already left is now logged quietly instead of as a `[SECURITY]` cross-room event, so genuine cross-room attempts stand out in the logs.
## [v2.4.2] — 2026-06-19
### Changed
- **Extension: Optimized uninstall URL registration** — Extracted registration into a reusable, race-condition-protected `initUninstallURL()` helper. It registers the uninstall feedback URL with browser context on both extension installation/update and browser startup to prevent state loss, without storing or sending an installation token.
## [v2.4.1] — 2026-06-19
### Added
- **Extension: Onboarding tour now has a closing step** — The first-run tour ends on a dedicated "You're all set!" card (the `ONBOARDING_5` copy that already existed in all 13 locales but was never shown). The tour no longer stops abruptly on the username step.
- **Extension: One-click invite from the empty peer list** — The "No peers yet" state now shows a **📋 Invite Link** button that copies the invite link to the clipboard, so users can share it without hunting for the field.
### Changed
- **Extension: Cleaner onboarding welcome** — Step 1 is now a centered welcome card instead of spotlighting the logo title. Added a guard so target-less tour steps center cleanly.
- **Website: Mobile comparison table** — The KoalaSync vs Teleparty table stacks into per-feature cards on phones instead of forcing horizontal scrolling; feature descriptions are shown again on mobile.
### Fixed
- **Extension: Onboarding step counter/progress placeholders** — Static `Step 1 of 3` / 33% fallbacks in `popup.html` corrected to match the actual 5-step tour (`Step 1 of 5` / 20%).
- **Website: Mobile navigation restored** — The header hamburger menu was hidden by a `display:none !important` rule, leaving the nav links unreachable on phones. Re-enabled, with spacing kept comfortable down to ~320px.
- **Website: Hero alignment on mobile** — A fixed-width extension mockup forced the hero grid column wider than the container, shifting all hero content off-center (larger left margin than right). The mockup is now responsive (`width:100%/max-width` + `minmax(0,1fr)` grid track).
- **Website: Reveal-animation fallback** — Added a `<noscript>` style fallback and `IntersectionObserver` feature guards so scroll-revealed content can never stay invisible if JavaScript is disabled or unsupported.
## [v2.4.0] — 2026-06-16
### Added
- **Extension: Lazy WebSocket connection** — The extension no longer maintains a permanent WebSocket connection to the relay server. Instead, the connection is established only when actively in a room or when the popup is opened with a saved room configuration. This improves privacy (IP is not exposed while idle), reduces battery/network usage, and prevents the server from tracking online status of inactive users. Automatic reconnect is guaranteed while in a room — zero behavior change during active sync sessions. See `connectIntent` flag in `background.js`.
- **Extension: Episode title regex unification** — `extractEpisodeId()` had inconsistent regex patterns between `background.js` and `content.js`. The content script correctly matched Crunchyroll-style separators (`S01/E01`) while the service worker's stricter pattern (`[\s\-\.]*`) silently rejected them, causing episode lobby sync failures. Now unified to `[^a-zA-Z0-9]*` via shared `episode-utils.js`.
- **Unit tests: `rate-limiter` and `episode-utils`** — 12 test groups for rate-limit functions and 30+ assertions for episode title parsing, covering all 6 separator types (dash, dot, slash, colon, comma, space). Run automatically via `npm run verify`.
### Changed
- **Server: Rate limiter extracted to `rate-limiter.js`** — 6 rate-limit functions, all rate-limit Maps, and cleanup intervals moved from `index.js` (149 lines). `index.js` now imports via facade pattern with re-exports for backward compatibility.
- **Extension: Episode utilities extracted to `episode-utils.js`** — `extractEpisodeId()` and `sameEpisode()` deduplicated from `background.js` and `content.js`. The shared module is imported as an ES module by the service worker and injected into the content script IIFE by the build script.
- **Build: `"type": "module"` in root `package.json`** — All scripts standardized to ESM (`.mjs`) or explicitly CommonJS (`.cjs`). Eliminated Node.js `MODULE_TYPELESS_PACKAGE_JSON` warnings.
- **Build: 4 CJS scripts renamed to `.cjs`** — `build-extension.js`, `test-content-video-finder.js`, `test-locales.js`, `website/build.js`.
### Fixed
- **Server: npm audit resolved** — `ws` package vulnerability (CVE-2024-37890) fixed. Zero vulnerabilities in production dependencies.
- **Pop-up: Connection status flicker fixed** — Removed hardcoded `disconnected` state on every pop-up open. Status now reflects actual background state from the first frame.
- **Pop-up: Join button timeout improved** — No longer blindly re-enables after 15s. Polls connection status and extends window if still connecting.
- **Pop-up: Validation failure state cleanup** — Custom server URL validation errors now properly reset `isProcessingConnection` and `joinBtnTimeout`.
- **Extension: `WEB_JOIN_REQUEST` channel leak fixed** — Missing `sendResponse()` call when already in the target room.
- **Extension: `LEAVE_ROOM` now clears `roomId` from storage** — Prevents phantom auto-reconnect on browser restart after explicit leave.
- **Extension: Reconnect attempt counters reset on leave** — Prevents stale `reconnecting` status display after intentional disconnect.
## [v2.3.2] — 2026-06-16
### Changed
- **Extension: Refined Spanish, Italian, and Portuguese translations**: Complete manual review and improvement of all Spanish (`es`), Italian (`it`), Portuguese — Brazil (`pt-BR`), and Portuguese — Portugal (`pt`) locale files for both the extension UI and the landing website. Thanks to [@Kaia-Alenia](https://github.com/Kaia-Alenia) for the native-quality translations.
### Fixed
- **Extension: Locale typos and corrupted characters fixed**: Repaired a Korean refresh button label (`Refreschi``새로고침`), a corrupted Korean connection status string (`연kel``연결`), a Korean character contaminating a Japanese string (`의``の`), and a Dutch typo (`cmmuniceren``communiceren`).
- **Server: Admin token length leak fixed (timing side-channel)**: `isAdminMetricsAuthorized()` returned early when the provided buffer had a different length than the expected token, leaking the token length via response timing. Now `crypto.timingSafeEqual` runs in constant time on every attempt regardless of length match. Reported by [@Kaia-Alenia](https://github.com/Kaia-Alenia).
---
## [v2.3.1] — 2026-06-15
### Fixed
- **Server: Concurrent peer join race condition and teardown error handling**
### Changed
- **Server: Smart unhandled rejection handling (exits after 5/min instead of 1)**
- **Server: Optimized admin health metrics allocation**
---
## [v2.3.0] — 2026-06-14
### Added
- **Extension: New Interactive Onboarding Tour**: A fully redesigned, interactive step-by-step onboarding experience.
- **Extension: Auto-Switch to Sync Tab**: The UI now intelligently switches to the Sync tab when you join a room to guide video selection.
- **Extension: Uninstall URL Integration**: Prepared an uninstall URL setup that works natively across Chrome and Firefox, cleanly attaching browser context for analytics.
### Fixed
- **Extension: Infinite Seek Loop Prevention**: Replaced the fragile time-based seek suppression with an exact target-time verification mechanism, entirely eliminating infinite seek loops on slow buffers.
- **Extension: Zombie Connections Resolved**: Implemented a forced disconnect upon ping timeouts, ensuring the extension reliably auto-reconnects when the WebSocket hangs in a half-open state.
- **Extension: Room Switching Architecture**: Joining a new room while already connected now explicitly severs the old connection first, preventing state cross-contamination.
- **Extension: Join/Leave Race Conditions**: Added UI locks to prevent users from accidentally sending conflicting connection commands via rapid double-clicking.
- **Extension: Same-Room Invite Bypass**: Clicking an invite link for the room you are currently in no longer triggers a redundant reconnect, instead instantly confirming the join.
- **Extension: Audio settings now propagate immediately to video tabs**: Changes made in the audio options page are now instantly applied to the active video tab. Previously, settings saved to `chrome.storage.local` were not picked up by the background listener, which only watched `chrome.storage.sync`.
- **Extension: Audio compressor now logs enable/disable state and resume failures**: The compressor reports when it is activated or bypassed, and warns if the `AudioContext` cannot be resumed (e.g. browser autoplay policy requires a user gesture on the page first).
- **Extension: Video heartbeat no longer sent when alone in a room**: The full media metadata `PEER_STATUS` is now only emitted when other peers are present. The session keepalive (background heartbeat) continues to run unaffected, preventing the server reaper from disconnecting idle peers.
- **Server: Increased `failedAuthAttempts` eviction threshold from 50k to 200k**: Reduces frequency of expensive batch evictions under high auth-failure volumes, smoothing heap usage.
---
## [v2.2.4] — 2026-06-10
### Fixed
@@ -64,7 +179,7 @@ All notable changes to the KoalaSync browser extension and relay server.
- **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.
- **Support Links**: Static footer badges on the Settings and Status tabs linking to the developer's support page. README and website footer updated with a Support KoalaSync 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".
+3 -3
View File
@@ -16,9 +16,9 @@ This guide walks through the complete user flow of KoalaSync, from creating a ro
## Step 2: Connecting to the Relay Server
When you open the extension popup, the background service worker connects to the relay server:
When you open the extension popup (with saved room credentials) or when a saved room configuration exists from a previous session, the background service worker connects to the relay server:
1. **WebSocket Handshake**: `background.js` opens a WebSocket to `wss://syncserver.koalastuff.net/socket.io/?EIO=4&transport=websocket`.
1. **WebSocket Handshake** (on demand): `background.js` opens a WebSocket to `wss://syncserver.koalastuff.net/socket.io/?EIO=4&transport=websocket` only when needed (popup opened or active room).
2. **Security Checks** (server-side):
- The server checks the client's **IP rate limit** (max 10 connections per 60 seconds).
- The server validates the **authentication token** (hardcoded in `shared/constants.js`) to verify this is a legitimate KoalaSync client.
@@ -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" and triggers aggressive reconnect (500ms base, max 5s) |
| **Background** | 30 seconds | `background.js` | While connected, signals "I'm still connected" and triggers automatic reconnect (500ms base, max 5s). No heartbeats fire when idle (lazy connect). |
| **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").
+1 -1
View File
@@ -33,7 +33,7 @@ The browser extension requires the following permissions:
- `storage`: To remember your local preferences (username, server URL, room settings).
- `tabs` & `scripting`: To detect and control video elements on the pages you choose to sync.
- `<all_urls>` (host permission): Required to detect `<video>` elements on any website the user chooses to synchronize. The extension only activates on the specific tab the user has actively selected — it does not scan, monitor, or interact with any other tabs or pages.
- `alarms`: To keep the background service worker alive during active sync sessions.
- `alarms`: To keep the background service worker alive during active sync sessions (the extension only stays connected while you are in a room).
- `notifications`: To display sync status updates (e.g., "Peer joined", "Force Sync initiated").
- **No History Access**: We do not read, store, or transmit your browsing history. We only interact with the specific tab you have actively selected for synchronization.
+1
View File
@@ -5,3 +5,4 @@ This directory contains deep-dives into the KoalaSync protocol and architecture.
- [HOW_IT_WORKS.md](HOW_IT_WORKS.md): Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
- [ARCHITECTURE.md](ARCHITECTURE.md): Communication flows, Dual Heartbeat, and Sync logic.
- [SYNC_GUIDE.md](SYNC_GUIDE.md): Protocol constants and sync requirements.
- [TRANSLATION.md](TRANSLATION.md): Translation and localization guide for the extension and website.
+74 -6
View File
@@ -1,14 +1,82 @@
# KoalaSync Roadmap
This document tracks future technical plans and optimizations for the KoalaSync system.
> Feature priorities, planned work, backlog, and rejected ideas for KoalaSync.
---
## Planned Optimizations & Technical Roadmap
## Status Legend
### 1. Split large JavaScript files (> 800 lines) into smaller modules
* **Category**: Maintainability / AI Context Optimization
* **Background**: Core files like `background.js` and `popup.js` have grown large and exceed 800 lines. This makes manual debugging harder and wastes context window space for AI models.
* **Planned solution**:
| Badge | Meaning |
|---|---|
| 🚧 In Progress | Currently being developed |
| 📋 Planned | Prioritized for an upcoming phase |
| 💡 Backlog | Under evaluation, not yet prioritized |
| ❌ Rejected | Declined (with rationale) |
| ✅ Completed | Shipped |
---
## 🚧 In Progress
*Currently being worked on.*
| Feature | Priority | Area |
|---|---|---|
| *(none yet)* | | |
---
## 📋 Planned
*Prioritized for upcoming phases.*
### 1. Split large JavaScript files into smaller modules
- **Priority:** P1
- **Category:** Maintainability / AI Context Optimization
- **Background:** Core files like `background.js` and `popup.js` have grown large and exceed 800 lines. This makes manual debugging harder and wastes context window space for AI models.
- **Planned solution:**
- Structurally split logic into separate focused modules (e.g., UI Renderer, Message Router, Storage Manager, Socket Client).
- Use ES modules for clean separation and better reusability.
### 2. Invite link with target URL for auto-redirect
- **Priority:** P2
- **Category:** UX / Ease of Sharing
- **Background:** The invite link currently only contains the room ID. The invited person has to manually open the page. Ideally, the link would include the shared tab's URL so the invitee gets redirected to the right page and the tab is auto-selected (auto-matching via tab title already exists).
- **Known challenges:**
- Many streaming sites (e.g., Emby, Jellyfin) don't have unique URLs per content — once inside the player, the URL stays the same.
- Dozens of such edge cases exist; a generic solution is difficult.
- Would likely need site-specific extractor logic (similar to the existing sync service adapters).
- **Possible approaches:**
- Fallback: if no unique URL can be determined, only pass the tab title.
- Site-specific URL extraction for known services.
---
## 💡 Backlog
*Ideas and feature requests under evaluation.*
### In-room chat overlay (like TeleParty)
- **Priority:** P3
- **Category:** Social / Communication
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
- **Why backlog (still uncertain):**
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
- **Status:** Under evaluation, may come later.
---
## ❌ Rejected
*Declined features with rationale — keeps decisions documented so they don't get re-debated.*
| Feature | Reason |
|---|---|
| *(none yet)* | |
+4 -2
View File
@@ -17,13 +17,15 @@ You MUST run the build script in any of the following scenarios:
Run the Node.js build script from the repository root:
```bash
node scripts/build-extension.js
node scripts/build-extension.cjs
# or simply:
npm run build:extension
```
## What does it do?
The build script performs the following actions:
1. Synchronizes protocol constants by copying `shared/constants.js`, `shared/blacklist.js`, and `shared/README.md` into `extension/shared/`.
2. Injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` via marker-based replacement.
2. Injects `EVENTS`, `HEARTBEAT_INTERVAL`, and `episode-utils.js` functions (`extractEpisodeId`, `sameEpisode`) into `content.js` via marker-based replacement.
3. Compiles browser-specific manifest files.
4. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
+121
View File
@@ -0,0 +1,121 @@
# KoalaSync Translation & Localization Guide
Welcome to the **KoalaSync** translation guide. We rely on the open-source community to make KoalaSync accessible to users worldwide.
KoalaSync is split into two independent translation areas. You can translate either one, or both:
1. **The Browser Extension** (`extension/locales/`): The core product that users interact with daily.
2. **The Website** (`website/locales/`): The landing page and invitation bridge.
---
## Supported Languages Dashboard
We divide supported languages into two tiers: **Core Languages** (fully hand-crafted and audited by native speakers) and **Extended Languages** (auto-generated using translation models to expand initial coverage).
> [!TIP]
> **Help Us Improve!**
> We welcome community contributions to audit `Auto-Generated` translations and elevate them to `100% Manually Verified` status.
| Language Code | Language Name | Verification Status | Rationale / Context |
| :--- | :--- | :--- | :--- |
| `en` | **English** | `100% Manually Verified` | Global default language (verified by developer) |
| `de` | **German** | `100% Manually Verified` | Developer's native language |
| `fr` | **French** | `Auto-Generated` | Needs manual native review and polishing |
| `es` | **Spanish** | `100% Manually Verified` | Manual native review by Alenia Studios |
| `pt-BR` | **Portuguese (Brazil)** | `100% Manually Verified` | Manual native review by Alenia Studios |
| `ru` | **Russian** | `Auto-Generated` | Needs manual native review and polishing |
| `it` | **Italian** | `100% Manually Verified` | Manual native review by Alenia Studios |
| `pl` | **Polish** | `Auto-Generated` | Needs manual native review and polishing |
| `tr` | **Turkish** | `Auto-Generated` | Needs manual native review and polishing |
| `nl` | **Dutch** | `Auto-Generated` | Needs manual native review and polishing |
| `ja` | **Japanese** | `Auto-Generated` | Needs manual native review and polishing |
| `ko` | **Korean** | `Auto-Generated` | Needs manual native review and polishing |
| `pt` | **European Portuguese** | `100% Manually Verified` | Manual native review by Alenia Studios |
| `zh` | **Chinese (Simplified)** | `Auto-Generated` | Needs manual native review and polishing |
| `uk` | **Ukrainian** | `Auto-Generated` | Needs manual native review and polishing |
> [!WARNING]
> **Autogeneration Quality Rule**
> Any newly contributed languages must be marked as `Auto-Generated` in this table until fully reviewed and signed off by a native speaker in a pull request.
---
## How to Translate KoalaSync
Here is the exact step-by-step process for contributing translations.
### Step 1: Fork and Clone the Repository
If you are an external contributor, start with the standard open-source workflow:
1. Click the "Fork" button on GitHub to create your own copy of the repository.
2. Clone your fork locally: `git clone https://github.com/YOUR-USERNAME/KoalaSync.git`
3. Create a branch: `git checkout -b translation/my-language`
### Step 2: Translate the Extension
The browser extension handles real-time syncing, settings, and popups.
1. Navigate to `extension/locales/`.
2. Edit an existing `[lang].json` or copy `en.json` to create a new one (for example, `it.json`).
3. Translate all string values. **Do not change the JSON keys.**
### Step 3: Translate the Website
The website hosts the landing page and invitation bridge.
1. Navigate to `website/locales/`.
2. Edit an existing `[lang].json` or copy `en.json` to create a new one.
3. Translate all string values. **Do not change the JSON keys.**
4. If creating a brand new language, configure the metadata at the top of your JSON file:
```json
{
"LANG_CODE": "it",
"HTML_CLASS": "lang-it",
"CANONICAL_PATH": "it/",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN"
}
```
5. If creating a brand new language, register it in `website/build.cjs` by adding it to the `languages` array.
### Step 4: Verify Locally
Ensure your JSON files are valid and all keys match the English baseline. Open your terminal in the KoalaSync root folder and run:
```bash
# Tests the extension locales for missing keys or syntax errors
node scripts/test-locales.cjs
# Tests the website locales for missing keys or syntax errors
node scripts/test-website-locales.mjs
# Builds the website with your new translations
node website/build.cjs
```
If you receive any errors about missing keys or placeholder strings, fix them before submitting.
### Step 5: Commit and Pull Request
1. Open this `TRANSLATION.md` file and add or update your language in the **Supported Languages Dashboard** above. Mark it as `100% Manually Verified` only if it has been reviewed by a native speaker.
2. Commit your changes: `git commit -m "Update Italian translations"`
3. Push to your fork: `git push origin translation/my-language`
4. Open a pull request on the main KoalaSync repository on GitHub.
---
## Strict Legal Exclusion Rule
Our legal pages have strict constraints to protect user privacy and avoid regulatory liabilities.
> [!IMPORTANT]
> **Do Not Translate Legal Documents**
> The legal notice (`website/impressum.html`) and privacy policy (`website/datenschutz.html`) **MUST remain exclusively in English and German**.
>
> **Rationale:** Legal compliance under the European Union General Data Protection Regulation (GDPR) and the German Digital Services Act (DDG). Offering automated translations of legally binding notices introduces compliance risks due to potential mistranslations of liability limits.
>
> **Technical fallback:** Our system automatically falls back to English for legal pages if a user visits them in an unsupported language, so you do not need to translate them.
+5 -2
View File
@@ -8,6 +8,7 @@ export default [
sourceType: "module",
globals: {
chrome: "readonly",
browser: "readonly",
window: "readonly",
document: "readonly",
navigator: "readonly",
@@ -36,8 +37,10 @@ export default [
URL: "readonly",
URLSearchParams: "readonly",
WebSocket: "readonly",
history: "readonly",
location: "readonly",
self: "readonly",
process: "readonly"
process: "readonly",
}
},
rules: {
@@ -55,7 +58,7 @@ export default [
}
},
{
files: ["server/**/*.js", "scripts/**/*.js", "website/build.js"],
files: ["server/**/*.js", "scripts/**/*.js", "scripts/**/*.cjs", "website/build.cjs"],
languageOptions: {
globals: {
require: "readonly",
+16 -4
View File
@@ -6,9 +6,9 @@ A Manifest V3 Browser Extension (Chrome & Firefox) for synchronized video playba
- **Manifest V3**: Optimized Service Worker architecture with session persistence.
- **Pure Vanilla JS**: No external dependencies or heavy libraries.
- **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.
- **On-Demand Connection**: The service worker only maintains a WebSocket connection while you're in a room. No persistent background connections — privacy-first architecture. Based on `connectIntent` flag that gates all reconnect attempts.
- **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.
- **Dynamic i18n (Multi-Language)**: Fully localized in 13 languages (`en`, `de`, `fr`, `es`, `it`, `pl`, `tr`, `nl`, `ja`, `ko`, `pt-BR`, `pt`, `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.
@@ -26,7 +26,7 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
## Installation
1. **Prepare Extension**: From the repository root, run:
```bash
node scripts/build-extension.js
npm run build:extension
```
2. Open Chrome and go to `chrome://extensions/`.
3. Enable **Developer mode** (top right).
@@ -35,6 +35,18 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
## Development
If you modify `shared/constants.js`, you must synchronize the changes by running the build script from the root:
```bash
node scripts/build-extension.js
npm run build:extension
```
This ensures that the `extension/shared` folder is updated with the latest protocol constants.
## Module Structure
| File | Purpose |
|---|---|
| `background.js` | Service worker: message routing, tab listeners, startup |
| `content.js` | Video detection, audio processing, episode transition (IIFE) |
| `popup.js` | Popup UI: join/create, tabs, status, settings |
| `bridge.js` | Landing page bridge (injected into sync.koalastuff.net) |
| `episode-utils.js` | Shared `extractEpisodeId()` / `sameEpisode()` — used by background.js, injected into content.js at build time |
| `i18n.js` | Translation loader |
| `shared/` | Constants, blacklist, name generator |
+1 -1
View File
@@ -3,6 +3,6 @@
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincroniza la reproducción de video en YouTube, Netflix, Emby, Jellyfin y cualquier sitio HTML5 en tiempo real con amigos."
"message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5."
}
}
+1 -1
View File
@@ -3,6 +3,6 @@
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
"message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5."
}
}
+1 -1
View File
@@ -3,6 +3,6 @@
"message": "KoalaSync"
},
"appDesc": {
"message": "Sincronize a reprodução de vídeo no YouTube, Netflix, Emby, Jellyfin e qualquer site HTML5 em tempo real com amigos."
"message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "Синхронізуйте відтворення відео на YouTube, Netflix, Emby, Jellyfin і будь-якому сайті HTML5 у режимі реального часу з друзями."
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"appName": {
"message": "KoalaSync"
},
"appDesc": {
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin和任何HTML5网站上的视频播放。"
}
}
+4 -8
View File
@@ -126,17 +126,13 @@ function setCustomParam(param, value) {
}
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();
// Local-only: audioSettings/locale are never read from storage.sync.
const { audioSettings, locale } = await chrome.storage.local.get(['audioSettings', 'locale']);
const lang = locale || getSystemLanguage();
await loadLocale(lang);
translateDOM();
currentSettings = mergeAudioSettings(audioData);
currentSettings = mergeAudioSettings(audioSettings);
render();
}
+340 -183
View File
@@ -1,7 +1,56 @@
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode } from './episode-utils.js';
import { initTabManager } from './modules/tab-manager.js';
// --- Uninstall URL Initialization ---
let uninstallURLInitPromise = null;
async function initUninstallURL() {
if (uninstallURLInitPromise) {
return uninstallURLInitPromise;
}
uninstallURLInitPromise = (async () => {
// --- UNINSTALL_URL_INJECT_START ---
const UNINSTALL_URL = ""; // Populated during build
const BROWSER_TYPE = "unknown";
// --- UNINSTALL_URL_INJECT_END ---
if (UNINSTALL_URL && UNINSTALL_URL.trim() !== '') {
try {
const url = new URL(UNINSTALL_URL);
url.searchParams.set("browser", BROWSER_TYPE);
const runtimeAPI = typeof browser !== 'undefined' ? browser.runtime : chrome.runtime;
if (runtimeAPI && runtimeAPI.setUninstallURL) {
const result = runtimeAPI.setUninstallURL(url.href);
// browser.runtime.setUninstallURL returns a Promise, handle rejection silently
if (result && typeof result.catch === 'function') {
result.catch(err => console.warn('Failed to set uninstall URL:', err));
}
}
} catch (err) {
console.error("Failed to initialize uninstall URL:", err);
}
}
})();
return uninstallURLInitPromise;
}
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install' || details.reason === 'update') {
initUninstallURL();
purgeLegacySyncKeys();
}
});
chrome.runtime.onStartup.addListener(() => {
initUninstallURL();
purgeLegacySyncKeys();
});
// --- State Management ---
let socket = null;
@@ -16,6 +65,7 @@ let storageInitialized = false;
let pendingLogs = [];
let pendingHistory = [];
let eventQueue = [];
let flushTimer = null; // paces draining of eventQueue after (re)connect
let isNamespaceJoined = false;
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
let localSeq = 0; // Monotonically increasing command sequence for this peer
@@ -28,6 +78,7 @@ let pingInterval = null;
let pingTimeout = null;
let pendingPingT = null;
let currentPingMs = null;
let missedPongs = 0;
// --- Keep-Alive Port Listener ---
chrome.runtime.onConnect.addListener((port) => {
@@ -149,9 +200,30 @@ let reconnectAttempts = 0;
let currentServerUrl = null;
let roomIdleSince = null;
let lastContentHeartbeatAt = null;
let connectIntent = false;
const MAX_RECONNECT_ATTEMPTS = 20;
const _RECONNECT_BASE_DELAY = 500;
const _RECONNECT_MAX_DELAY = 5000;
// Backoff tuned so that at most ~8 connection attempts land in any 60s window,
// keeping a single client comfortably under the server's per-IP connection
// budget (10/min) even before jitter. Cumulative (no jitter): 1, 2.8, 6, 11.9,
// 22.4, 34.4, 46.4, 58.4s → 8th attempt at ~58s.
const _RECONNECT_BASE_DELAY = 1000;
const _RECONNECT_MAX_DELAY = 12000;
const _RECONNECT_FACTOR = 1.8;
const _RECONNECT_GIVEUP_MS = 300000; // switch to slow mode after 5 min of fast retries
const _RECONNECT_SLOW_DELAY = 300000; // slow-mode interval: every 5 min
const _RECONNECT_JITTER = 0.2; // ±20% randomization to de-synchronize reconnect herds
// Paced queue flush: after a (re)connect we drain the offline event backlog in
// small batches instead of one synchronous burst, so we stay well under the
// server's per-socket event budget (50 / 10s) and leave headroom for the
// heartbeats/pings/commands that also count toward it. 10 per 3s ≈ 33/10s.
const FLUSH_BATCH_SIZE = 10;
const FLUSH_BATCH_INTERVAL_MS = 3000;
// Ping liveness: a single unanswered ping is tolerated (transient network
// blip); only MAX_MISSED_PONGS consecutive misses force a reconnect. With a
// 15s interval and 5s timeout that means ~20s to detect a genuinely dead link.
const PING_INTERVAL_MS = 15000;
const PING_TIMEOUT_MS = 5000;
const MAX_MISSED_PONGS = 2;
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
// Force Sync Coordination
@@ -163,26 +235,6 @@ let forceSyncTimeout = null;
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
let episodeLobbyTimeout = null;
// --- Episode Title Extraction (synced with content.js) ---
function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null;
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
return null;
}
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
}
// --- Storage Utils ---
/**
@@ -236,29 +288,14 @@ async function getPeerId() {
}
async function getSettings() {
// Try local (per-device) first, fall back to sync for migration
let data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
let migrated = false;
if (!data.username) {
const syncData = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
if (syncData.username || syncData.roomId) {
data = syncData;
migrated = true;
}
}
// Local-only by design. Room credentials (roomId/password) and identity
// (username) must NEVER come from storage.sync — syncing them across devices
// both leaks them and resurrects dead rooms on reinstall (a fresh install
// has empty local storage but sync survives in the user's Google account).
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
let username = data.username;
if (!username) {
username = generateUsername();
}
if (migrated) {
await chrome.storage.local.set({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username
});
} else if (!data.username) {
await chrome.storage.local.set({ username });
}
return {
@@ -270,6 +307,19 @@ async function getSettings() {
};
}
// Privacy + correctness: only onboardingComplete and dismissedHints belong in
// storage.sync. Everything else is per-device local storage. This actively
// removes legacy keys that older versions wrote to sync (and that would
// otherwise be redistributed across devices and resurrected on reinstall).
const LEGACY_SYNC_KEYS = [
'serverUrl', 'useCustomServer', 'roomId', 'password', 'username',
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings'
];
function purgeLegacySyncKeys() {
chrome.storage.sync.remove(LEGACY_SYNC_KEYS).catch(() => {});
}
function addLog(message, type = 'info') {
const log = {
timestamp: new Date().toISOString(),
@@ -322,6 +372,7 @@ function forceDisconnect() {
roomIdleSince = null;
lastContentHeartbeatAt = null;
forceSyncAcks.clear();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
eventQueue = [];
chrome.storage.session.set({
isForceSyncInitiator: false,
@@ -377,7 +428,12 @@ function clearTargetTabForIdle() {
async function leaveRoomAfterIdleGrace(reason) {
if (!currentRoom) return;
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
emit(EVENTS.LEAVE_ROOM, { peerId });
forceDisconnect();
currentRoom = null;
currentTabId = null;
currentTabTitle = null;
@@ -430,7 +486,9 @@ async function connect() {
addLog('Browser is offline. Waiting...', 'warn');
broadcastConnectionStatus('offline');
isConnecting = false;
scheduleReconnect();
if (currentRoom || connectIntent) {
scheduleReconnect();
}
return;
}
@@ -512,12 +570,7 @@ async function connect() {
protocolVersion: PROTOCOL_VERSION
});
}
while (eventQueue.length > 0) {
const queuedMsg = eventQueue.shift();
emit(queuedMsg.event, queuedMsg.data);
}
eventQueue = [];
chrome.storage.session.set({ eventQueue: [] });
flushEventQueue();
} else if (msg.startsWith('42')) {
try {
const payload = JSON.parse(msg.substring(2));
@@ -536,26 +589,34 @@ async function connect() {
isConnecting = false;
isNamespaceJoined = false;
stopPing();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
isForceSyncInitiator = false;
forceSyncAcks.clear();
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
chrome.storage.session.set({
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
}).catch(() => {});
if (!connectIntent && !currentRoom) {
isForceSyncInitiator = false;
forceSyncAcks.clear();
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
chrome.storage.session.set({
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
}).catch(() => {});
}
if (currentRoom) {
if (currentRoom && !connectIntent) {
currentRoom.peers = [];
if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {});
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
addLog('Disconnected. Scheduling reconnect...', 'warn');
socket = null;
scheduleReconnect();
if (currentRoom || connectIntent) {
addLog('Disconnected. Scheduling reconnect...', 'warn');
socket = null;
scheduleReconnect();
} else {
addLog('Disconnected. No active session — staying disconnected.', 'info');
socket = null;
}
};
socket.onerror = () => {
@@ -570,7 +631,9 @@ async function connect() {
const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error');
addLog(errMsg, logType);
broadcastConnectionStatus('disconnected');
scheduleReconnect();
if (currentRoom || connectIntent) {
scheduleReconnect();
}
}
}
@@ -645,17 +708,22 @@ function scheduleReconnect() {
const elapsed = Date.now() - reconnectStartTime;
reconnectAttempts++;
if (!reconnectFailed && (elapsed > 300000 || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
if (!reconnectFailed && (elapsed > _RECONNECT_GIVEUP_MS || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
reconnectFailed = true;
addLog('Switching to slow reconnect mode (every 5 minutes)', 'warn');
}
const delay = reconnectFailed
? 300000
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
const baseDelay = reconnectFailed
? _RECONNECT_SLOW_DELAY
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(_RECONNECT_FACTOR, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
// Jitter de-synchronizes herds: many clients dropped by the same server
// blip won't all reconnect on the same tick and exhaust the connection
// budget in lockstep. Applied in both fast and slow mode.
const jitterFactor = 1 - _RECONNECT_JITTER + Math.random() * 2 * _RECONNECT_JITTER;
const delay = Math.round(baseDelay * jitterFactor);
if (reconnectFailed) {
addLog(`Slow reconnect in 5min (attempt ${reconnectAttempts})`, 'info');
addLog(`Slow reconnect in ~5min (attempt ${reconnectAttempts})`, 'info');
} else {
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
}
@@ -673,17 +741,58 @@ function scheduleReconnect() {
function emit(event, data) {
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
const msg = `42${JSON.stringify([event, data])}`;
socket.send(msg);
} else {
eventQueue.push({ event, data });
if (eventQueue.length > 50) {
eventQueue.shift();
addLog('Event queue cap reached, dropping oldest event', 'warn');
try {
socket.send(msg);
} catch (e) {
// The socket can close between the readyState check and send()
// (race with a server-side disconnect). Re-queue so the event is
// retried on the next successful (re)connect instead of being lost.
addLog(`Send failed, re-queueing ${event}: ${e.message}`, 'warn');
queueEvent(event, data);
}
chrome.storage.session.set({ eventQueue });
} else {
queueEvent(event, data);
}
}
function queueEvent(event, data) {
eventQueue.push({ event, data });
if (eventQueue.length > 50) {
eventQueue.shift();
addLog('Event queue cap reached, dropping oldest event', 'warn');
}
chrome.storage.session.set({ eventQueue });
}
/**
* Drain the offline event queue in paced batches. A reconnect after a long
* outage can leave up to 50 queued events; dumping them in one tick would
* exceed the server's per-socket event budget and get us disconnected right
* after rejoining. We send FLUSH_BATCH_SIZE events, then wait
* FLUSH_BATCH_INTERVAL_MS before the next batch. Remaining events drain across
* subsequent batches; if the connection drops mid-drain, the rest stay queued.
*/
function flushEventQueue() {
if (flushTimer) return; // a drain is already in progress
const drainBatch = () => {
flushTimer = null;
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
return; // lost the connection — leave the rest queued for next connect
}
let sent = 0;
while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) {
const queuedMsg = eventQueue.shift();
emit(queuedMsg.event, queuedMsg.data);
sent++;
}
chrome.storage.session.set({ eventQueue }).catch(() => {});
if (eventQueue.length > 0) {
flushTimer = setTimeout(drainBatch, FLUSH_BATCH_INTERVAL_MS);
}
};
drainBatch();
}
function addToHistory(action, senderId) {
const historyEntry = {
action,
@@ -707,11 +816,23 @@ function sendPing() {
emit(EVENTS.PING, { t });
if (pingTimeout) clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {
if (pendingPingT === t) {
pendingPingT = null;
}
pingTimeout = null;
}, 5000);
if (pendingPingT !== t) return; // a PONG arrived in time
// This ping went unanswered. Tolerate transient blips: only force a
// reconnect after MAX_MISSED_PONGS consecutive misses, not the first.
pendingPingT = null;
missedPongs++;
if (missedPongs >= MAX_MISSED_PONGS) {
addLog(`${missedPongs} consecutive pings unanswered — force disconnecting to trigger reconnect`, 'warn');
missedPongs = 0;
forceDisconnect();
if (currentRoom || connectIntent) {
scheduleReconnect();
}
} else {
addLog(`Ping unanswered (${missedPongs}/${MAX_MISSED_PONGS}) — retrying next interval`, 'warn');
}
}, PING_TIMEOUT_MS);
}
function startPing() {
@@ -719,7 +840,8 @@ function startPing() {
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
currentPingMs = null;
pendingPingT = null;
pingInterval = setInterval(sendPing, 15000);
missedPongs = 0;
pingInterval = setInterval(sendPing, PING_INTERVAL_MS);
sendPing();
}
@@ -734,6 +856,7 @@ function stopPing() {
}
currentPingMs = null;
pendingPingT = null;
missedPongs = 0;
}
// --- Event Handlers ---
@@ -807,6 +930,14 @@ function handleServerEvent(event, data) {
break;
case EVENTS.ERROR:
isConnecting = false;
// If we get a server error before successfully joining a room,
// clear connectIntent to prevent an infinite reconnect loop.
if (!currentRoom) {
connectIntent = false;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
reconnectAttempts = 0;
reconnectFailed = false;
}
broadcastConnectionStatus('disconnected');
addLog(`Server Error: ${data.message}`, 'error');
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
@@ -950,13 +1081,21 @@ function handleServerEvent(event, data) {
if (!Array.isArray(currentRoom.peers)) currentRoom.peers = [];
if (data.status === 'joined') {
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
const wasSolo = currentRoom.peers.filter(p => (p.peerId || p) !== peerId).length === 0;
delete lastSeqBySender[data.peerId];
_persistLastSeq();
currentRoom.peers.push(createPeerData(data));
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
// We were alone and now we're not — proactively push our
// current playback state so the newcomer syncs immediately
// instead of waiting up to a full heartbeat interval.
if (wasSolo && currentTabId) {
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {});
}
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: episodeLobby.expectedTitle });
}
@@ -1066,6 +1205,7 @@ function handleServerEvent(event, data) {
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
if (pendingPingT === data.t) {
pendingPingT = null;
missedPongs = 0;
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
@@ -1305,7 +1445,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'keepAlive') {
chrome.storage.session.get('keepAlive', () => {});
if (!socket || socket.readyState !== WebSocket.OPEN) {
if (!reconnectFailed) {
if (!reconnectFailed && (currentRoom || connectIntent)) {
connect();
}
} else if (currentRoom) {
@@ -1318,14 +1458,18 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
await leaveRoomAfterIdleGrace('Left room after 2 hours without a selected video heartbeat.');
return;
}
// Heartbeat Logic: Always include identity metadata
const settings = await getSettings();
emit(EVENTS.PEER_STATUS, {
peerId,
status: 'heartbeat',
username: settings.username,
tabTitle: currentTabTitle
});
// Heartbeat — only broadcast when someone else is in the room.
// Recomputed live so a freshly joined peer is picked up immediately.
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
if (otherCount > 0) {
const settings = await getSettings();
emit(EVENTS.PEER_STATUS, {
peerId,
status: 'heartbeat',
username: settings.username,
tabTitle: currentTabTitle
});
}
}
}
});
@@ -1333,9 +1477,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
function leaveOldRoomIfSwitching(newRoomId) {
if (currentRoom && currentRoom.roomId !== newRoomId) {
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
emit(EVENTS.LEAVE_ROOM, { peerId });
}
forceDisconnect();
currentRoom = null;
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -1364,14 +1506,8 @@ function resetAudioProcessingInTab(tabId) {
async function applyAudioSettingsToTab(tabId) {
if (!tabId) return;
let data = (await chrome.storage.local.get(['audioSettings']));
if (!data.audioSettings) {
const syncData = await chrome.storage.sync.get(['audioSettings']);
if (syncData.audioSettings) {
data = syncData;
await chrome.storage.local.set({ audioSettings: syncData.audioSettings });
}
}
// Local-only: audioSettings are never read from storage.sync.
const data = await chrome.storage.local.get(['audioSettings']);
chrome.tabs.sendMessage(tabId, {
action: 'APPLY_AUDIO_SETTINGS',
settings: data.audioSettings
@@ -1389,18 +1525,31 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await ensureState();
if (message.type === 'CONNECT') {
const settings = await getSettings();
connectIntent = !!settings.roomId;
const desiredUrl = resolveServerUrl(settings);
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
if (typeof sendResponse === 'function') sendResponse({ status: 'ok' });
return;
}
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
const settings = await getSettings();
if (settings.roomId) {
leaveOldRoomIfSwitching(settings.roomId);
}
const desiredUrl = resolveServerUrl(settings);
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
if (desiredUrl !== currentServerUrl) forceDisconnect();
connect();
if (settings.roomId) connect();
} else if (settings.roomId) {
emit(EVENTS.JOIN_ROOM, {
roomId: settings.roomId,
@@ -1413,6 +1562,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
sendResponse({ status: 'ok' });
} else if (message.type === 'RETRY_CONNECT') {
connectIntent = true;
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
@@ -1441,6 +1591,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
ping: currentPingMs
});
} else if (message.type === 'LEAVE_ROOM') {
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
resetAudioProcessingInTab(currentTabId);
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
@@ -1471,8 +1625,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
episodeLobby: null,
expectedAcksCount: 0
});
chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
addLog('Left Room', 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
forceDisconnect();
sendResponse({ status: 'ok' });
} else if (message.type === 'CLEAR_LOGS') {
logs = [];
@@ -1487,24 +1643,46 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'WEB_JOIN_REQUEST') {
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
if (!roomId) {
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
chrome.runtime.sendMessage(errMsg).catch(() => {});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => chrome.tabs.sendMessage(tab.id, errMsg).catch(() => {}));
});
sendResponse({ status: 'invalid_room_id' });
return;
}
connectIntent = true;
chrome.storage.local.set({
roomId,
password,
useCustomServer: !!useCustomServer,
serverUrl: serverUrl || ''
}, async () => {
const settings = await getSettings();
const desiredUrl = resolveServerUrl(settings);
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
sendResponse({ status: 'already_joined' });
return;
}
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
broadcastConnectionStatus('connecting');
leaveOldRoomIfSwitching(roomId);
const settings = await getSettings();
const desiredUrl = resolveServerUrl(settings);
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
if (desiredUrl !== currentServerUrl) forceDisconnect();
connect();
} else {
} else if (roomId) {
emit(EVENTS.JOIN_ROOM, {
roomId,
password,
@@ -1540,6 +1718,19 @@ async function handleAsyncMessage(message, sender, sendResponse) {
});
} else if (message.type === 'CONTENT_EVENT') {
const processEvent = () => {
// Live solo check — recomputed from the current peer list on every
// event (the list is updated synchronously on PEER_STATUS join/leave),
// never cached, so the instant a peer joins we resume sending.
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
const hasOtherPeers = otherCount > 0;
// Force Sync only makes sense with other peers. Solo it is a no-op:
// skip the pause/seek + ACK-wait entirely (no freeze, no server traffic).
if (message.action === EVENTS.FORCE_SYNC_PREPARE && !hasOtherPeers) {
sendResponse({ status: 'ok_solo' });
return;
}
const timestamp = Date.now();
localSeq++;
chrome.storage.session.set({ localSeq });
@@ -1583,11 +1774,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}, FORCE_SYNC_TIMEOUT);
}
addToHistory(message.action, 'You');
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
const hasOtherPeers = otherCount > 0;
if (isNonEssentialEvent && !hasOtherPeers) {
sendResponse({ status: 'ok_solo' });
return;
@@ -1639,11 +1827,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') {
const commandSenderId = message.commandSenderId;
if (commandSenderId && commandSenderId !== peerId) {
emit(EVENTS.EVENT_ACK, {
senderId: peerId,
// Only ACK if the command sender is still a known peer in our room.
// If we've already seen their PEER_STATUS 'left', skip the ACK — it would
// only be dropped server-side as an absent-peer ACK anyway.
const senderStillPresent = currentRoom && Array.isArray(currentRoom.peers) &&
currentRoom.peers.some(p => (typeof p === 'object' ? p.peerId : p) === commandSenderId);
if (commandSenderId && commandSenderId !== peerId && senderStillPresent) {
emit(EVENTS.EVENT_ACK, {
senderId: peerId,
targetId: commandSenderId,
actionTimestamp: message.actionTimestamp
actionTimestamp: message.actionTimestamp
});
}
sendResponse({ status: 'ok' });
@@ -1664,7 +1857,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
markRoomUseful();
getSettings().then(settings => {
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
emit(EVENTS.PEER_STATUS, statusPayload);
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload);
if (currentRoom && Array.isArray(currentRoom.peers)) {
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
@@ -1741,6 +1935,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return;
}
// Variant A: alone in the room → no one to wait for. Skip the lobby
// entirely so the next episode just plays through (no pause, no traffic).
// Live peer check, so the moment someone joins the next transition syncs.
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
if (otherCount === 0) {
addLog(`Episode change ("${newTitle}") — alone in room, playing through without a lobby.`, 'info');
sendResponse({ status: 'solo_no_lobby' });
return;
}
// If lobby already exists for this title, just mark self ready
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, newTitle)) {
if (!episodeLobby.readyPeers.includes(peerId)) {
@@ -1820,73 +2024,26 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
}
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'sync' || !changes.audioSettings) return;
await ensureState();
if (!currentTabId) return;
chrome.tabs.sendMessage(currentTabId, {
action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue
}).catch(() => {});
initTabManager({
getCurrentTabId: () => currentTabId,
setCurrentTabId: (val) => { currentTabId = val; },
setCurrentTabTitle: (val) => { currentTabTitle = val; },
setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; },
setRoomIdleSince: (val) => { roomIdleSince = val; },
getCurrentRoom: () => currentRoom,
getPeerId: () => peerId,
getStorageInitialized: () => storageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
ensureState,
EVENTS
});
// Tab removal listener
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
if (tabId === currentTabId) {
const wasInRoom = !!currentRoom;
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
roomIdleSince = Date.now();
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null, roomIdleSince, lastContentHeartbeatAt });
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
if (wasInRoom) {
const roomAtClose = currentRoom;
getSettings().then(settings => {
if (currentRoom !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId,
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
if (currentRoom && Array.isArray(currentRoom.peers)) {
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
// Re-inject on full page refresh
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
await ensureState();
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
})
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
}
});
// Initial Connect
connect();
// Initial Connect — only if user has an active room configuration
getSettings().then(settings => {
connectIntent = !!settings.roomId;
if (connectIntent) connect();
}).catch(() => connectIntent = false);
+129 -53
View File
@@ -55,6 +55,30 @@
const MIN_SEEK_DELTA = 2.0;
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
let seekDebounceTimer = null; // debounce timer for rapid seek events
let expectedSeekTime = null; // strictly track programmatic seeks
// --- Play/Pause Coalescing (leading + trailing) ---
// Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps,
// page teardown) fire bursts of native play/pause events within a few hundred
// ms. Relaying each as a distinct command spams peers and the relay.
//
// Strategy: emit the FIRST event immediately (leading edge → a deliberate
// single play/pause has zero added latency), then hold a short window. If
// more play/pause events arrive during the window it's a burst — we suppress
// the intermediate churn and, once it settles, emit the FINAL state on the
// trailing edge.
//
// We deliberately do NOT dedup the trailing send against the leading one. A
// remote play/pause may be applied mid-window (e.g. I pause, peer plays, I
// pause again within 150ms): the settled state then equals my last *sent*
// state yet is a genuine change versus the now-shared state — suppressing it
// would desync. Re-sending an unchanged state is a harmless no-op on peers,
// so re-sending is always the safe choice. Echo-suppression and seek-flush
// still run synchronously on event arrival (see reportEvent) — only the
// network emit is governed here.
const PLAY_PAUSE_COALESCE_MS = 150;
let playPauseCoalesceTimer = null; // non-null = a coalescing window is open
let pendingPlayPauseAction = null; // last play/pause seen during the window, awaiting trailing flush
// --- Episode Auto-Sync State ---
let lastKnownMediaTitle = null;
@@ -65,25 +89,8 @@
let _audioSettings = null;
let _audioProcessingAllowed = true;
// Cache the autoSyncNextEpisode setting
// Cache the autoSyncNextEpisode setting (local-only; never read from sync)
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();
@@ -184,7 +191,7 @@
}
}
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
audioCtx.resume().catch(() => { reportLog('AudioContext resume failed - browser may need page interaction first', 'warn'); });
}
return audioCtx;
}
@@ -242,6 +249,7 @@
rampGain(chain.dryGain, 1, t);
rampGain(chain.compGain, 0, t);
chain.active = false;
reportLog('Audio compressor disabled', 'info');
}
function bypassCurrentAudioProcessing() {
@@ -274,6 +282,7 @@
rampGain(chain.dryGain, 0, t);
rampGain(chain.compGain, 1, t);
chain.active = true;
reportLog('Audio compressor enabled', 'info');
}
}
@@ -287,28 +296,27 @@
// 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.
// --- SHARED_EPISODE_UTILS_INJECT_START ---
// This block is automatically replaced by /scripts/build-extension.js
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
if (!titleA && !titleB) return true;
if (!titleA || !titleB) return false;
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
if (idA && idB) return idA === idB;
if (idA || idB) return false;
return titleA === titleB;
}
// --- SHARED_EPISODE_UTILS_INJECT_END ---
// Returns true only when we are CERTAIN the episodes differ.
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
@@ -436,7 +444,7 @@
ytButton.click();
}
if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
return;
@@ -452,7 +460,7 @@
twitchButton.click();
}
if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
return;
@@ -470,7 +478,7 @@
_setSuppress('paused');
video.pause();
} else if (action === EVENTS.SEEK) {
_setSuppress('seek');
expectedSeekTime = data.targetTime;
video.currentTime = data.targetTime;
}
} catch (e) {
@@ -530,6 +538,14 @@
return true;
}
// Background asks for an immediate state push (e.g. the first peer just
// joined while we were solo) so the newcomer syncs without waiting.
if (message.type === 'REQUEST_HEARTBEAT') {
sendHeartbeat();
sendResponse({ ok: true });
return true;
}
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
let actionCompleted = false;
@@ -573,7 +589,7 @@
return;
}
_setSuppress('paused');
_setSuppress('seek');
expectedSeekTime = payload.targetTime;
video.pause();
try {
video.currentTime = payload.targetTime;
@@ -766,6 +782,46 @@
});
// Detect native events
// Build the relay payload from the *current* media state and send it to
// background.js. Re-reads the video each call so deferred (coalesced) emits
// carry an up-to-date position. Safe with no/invalid video — it no-ops.
function sendContentEvent(action) {
const video = findVideo();
if (!video) return;
const current = video.currentTime;
if (!Number.isFinite(current)) return;
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
action,
payload: {
currentTime: current,
targetTime: current,
mediaTitle: mediaTitle,
timestamp: Date.now()
}
}).catch(() => {});
// Trigger proactive heartbeat to push stabilized state
scheduleProactiveHeartbeat();
}
// Trailing-edge flush of a coalesced play/pause burst: emit the final settled
// state. No-ops only when no burst followed the leading edge. We do NOT skip
// when the state matches the leading send — a remote command may have changed
// the shared state mid-window, so re-sending is the safe (idempotent) choice.
function flushPlayPause() {
playPauseCoalesceTimer = null;
const finalAction = pendingPlayPauseAction;
pendingPlayPauseAction = null;
// No burst follow-up (only the leading edge fired), or invalid state.
if (finalAction !== EVENTS.PLAY && finalAction !== EVENTS.PAUSE) return;
sendContentEvent(finalAction);
}
function reportEvent(action) {
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
clearTimeout(seekDebounceTimer);
@@ -784,10 +840,11 @@
const current = video.currentTime;
if (!Number.isFinite(current)) return;
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
// Echo-suppression for remotely-applied commands. MUST stay synchronous
// on event arrival: the suppress timer is short-lived (300ms) and would
// expire if deferred, leaking an echo back to peers.
if (_suppressTimers[eventState]) {
_clearSuppress(eventState);
return;
@@ -796,20 +853,29 @@
// 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',
action,
payload: {
currentTime: current,
targetTime: current,
mediaTitle: mediaTitle,
timestamp: Date.now()
}
}).catch(() => {});
// Trigger proactive heartbeat to push stabilized state
scheduleProactiveHeartbeat();
// Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The
// synchronous gates above have already run; only the network emit is
// governed here. Leading edge sends the first event instantly; further
// events within the window are collapsed to the final state by the
// trailing flush. A pending burst is REPLACED, never dropped — the last
// event in the window always wins.
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
if (playPauseCoalesceTimer === null) {
// Window closed → fresh action. Emit now (zero added latency).
sendContentEvent(action);
pendingPlayPauseAction = null;
} else {
// Window open → part of a burst. Defer to the trailing flush.
pendingPlayPauseAction = action;
clearTimeout(playPauseCoalesceTimer);
}
playPauseCoalesceTimer = setTimeout(flushPlayPause, PLAY_PAUSE_COALESCE_MS);
return;
}
// SEEK (and any non play/pause action): emit immediately.
sendContentEvent(action);
}
// --- Tab Visibility Handling ---
@@ -841,6 +907,10 @@
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
// Drop any pending coalesced play/pause: we are tearing down and will
// disconnect — peers learn we are gone via PEER_STATUS 'left'. This also
// intentionally suppresses the teardown play/pause burst at the source.
if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; pendingPlayPauseAction = null; }
observer.disconnect();
});
window.addEventListener('pageshow', (event) => {
@@ -863,11 +933,17 @@
const current = video.currentTime;
if (!Number.isFinite(current)) return;
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
if (_suppressTimers['seek']) {
_clearSuppress('seek');
lastReportedSeekTime = current;
return;
// Step 1: Check expectedSeekTime (programmatic seek from remote peer)
if (expectedSeekTime !== null) {
if (Math.abs(current - expectedSeekTime) < 1.0) {
// Video arrived at expected time. Safely clear and ignore.
expectedSeekTime = null;
lastReportedSeekTime = current;
return;
} else {
// User manually scrubbed to a DIFFERENT time while we were buffering
expectedSeekTime = null;
}
}
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
+24
View File
@@ -0,0 +1,24 @@
/**
* KoalaSync Episode Title Utilities
* Single source of truth — synced to content.js by build-extension.js.
* Keep in sync with the injection block in content.js!
*/
export function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null;
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')}`;
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
return null;
}
export function sameEpisode(titleA, titleB) {
if (!titleA && !titleB) return true;
if (!titleA || !titleB) return false;
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
if (idA && idB) return idA === idB;
if (idA || idB) return false;
return titleA === titleB;
}
+1 -1
View File
@@ -1,5 +1,5 @@
// extension/i18n.js
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'];
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'];
export const DEFAULT_LANGUAGE = 'en';
let activeDictionary = {};
+5 -2
View File
@@ -181,7 +181,7 @@
"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_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Bewerten",
"FOOTER_SUPPORT_PROMPT": "Gefällt dir KoalaSync? Hinterlasse eine Bewertung!",
"LABEL_AUDIO_PROCESSING": "Audio-Verarbeitung",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Demnächst"
"AUDIO_COMING_SOON": "Demnächst",
"BTN_RESTART_TOUR": "Einführung neu starten",
"BTN_RESTART_TOUR_TOOLTIP": "Startet die Einführung für neue Benutzer erneut",
"HINT_SELECT_VIDEO": "Wähle hier dein Video aus!"
}
+5 -2
View File
@@ -181,7 +181,7 @@
"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_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Rate us",
"FOOTER_SUPPORT_PROMPT": "Enjoying KoalaSync? Leave a review!",
"LABEL_AUDIO_PROCESSING": "Audio Processing",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Coming soon"
"AUDIO_COMING_SOON": "Coming soon",
"BTN_RESTART_TOUR": "Restart Tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Restart the onboarding tutorial",
"HINT_SELECT_VIDEO": "Select your video here!"
}
+29 -26
View File
@@ -6,7 +6,7 @@
"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": "Configuración",
"TAB_SETTINGS_TOOLTIP": "Preferencias de la extensión",
"TAB_STATUS": "Estado",
"TAB_STATUS_TOOLTIP": "Diagnósticos avanzados y registros",
@@ -15,7 +15,7 @@
"LABEL_SERVER": "Servidor",
"BTN_SERVER_OFFICIAL": "Oficial",
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar el servidor oficial confiable",
"BTN_SERVER_CUSTOM": "Perso",
"BTN_SERVER_CUSTOM": "Personalizado",
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectarse a su propio servidor auto-alojado",
"PLACEHOLDER_SERVER_URL": "wss://tu-servidor:3000",
"LABEL_ROOM_ID": "ID de sala",
@@ -58,16 +58,16 @@
"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_TO_OTHERS": "Ir a la posición de otros",
"OPTION_JUMP_TO_ME": "Traerlos a mi posición",
"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",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Muestra el comando más reciente de reproducción, pausa o salto en el tiempo",
"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": "Omitir y reproducir ya",
"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.",
@@ -76,7 +76,7 @@
"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": "Ocultar pestañas sin video",
"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.",
@@ -88,10 +88,10 @@
"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": "Regenerar ID de usuario",
"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",
"REGEN_ID_OTHER_ISSUE": "¿Tienes otro problema? Abre un reporte 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",
@@ -115,7 +115,7 @@
"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_1_TEXT": "Mira videos junto a tus amigos 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",
@@ -127,7 +127,7 @@
"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_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mi posición'.",
"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!",
@@ -135,8 +135,8 @@
"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_JOINED": "{name} se ha unido a la sala",
"TOAST_PEER_LEFT": "{name} ha salido de la sala",
"TOAST_PEER_ACTION": "{name} ha {action}",
"STATUS_CONNECTED": "Conectado",
"STATUS_RECONNECTING": "Reconectando...",
@@ -150,17 +150,17 @@
"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",
"NOTIF_PLAY": "ha iniciado la reproducción",
"NOTIF_PAUSE": "ha pausado la reproducción",
"NOTIF_SEEK": "ha cambiado la posición del video",
"NOTIF_FORCE_PREPARE": "ha iniciado una sincronización forzada",
"NOTIF_FORCE_EXECUTE": "ha sincronizado 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_HISTORY_HINT": "Reproduce, pausa o salta en el tiempo 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",
@@ -181,7 +181,7 @@
"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_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Valorar",
"FOOTER_SUPPORT_PROMPT": "¿Te gusta KoalaSync? ¡Deja una reseña!",
"LABEL_AUDIO_PROCESSING": "Procesamiento de audio",
@@ -199,11 +199,14 @@
"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_PARAM_THRESHOLD": "Umbral (Threshold)",
"AUDIO_PARAM_KNEE": "Codo (Knee)",
"AUDIO_PARAM_RATIO": "Relación (Ratio)",
"AUDIO_PARAM_ATTACK": "Ataque (Attack)",
"AUDIO_PARAM_RELEASE": "Liberación (Release)",
"AUDIO_EQUALIZER": "Ecualizador",
"AUDIO_COMING_SOON": "Próximamente"
"AUDIO_COMING_SOON": "Próximamente",
"BTN_RESTART_TOUR": "Reiniciar tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar el tutorial de inicio",
"HINT_SELECT_VIDEO": "¡Selecciona tu video aquí!"
}
+5 -2
View File
@@ -181,7 +181,7 @@
"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_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Évaluer",
"FOOTER_SUPPORT_PROMPT": "Tu aimes KoalaSync? Laisse un avis!",
"LABEL_AUDIO_PROCESSING": "Traitement audio",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Égaliseur",
"AUDIO_COMING_SOON": "Bientôt disponible"
"AUDIO_COMING_SOON": "Bientôt disponible",
"BTN_RESTART_TOUR": "Redémarrer le tutoriel",
"BTN_RESTART_TOUR_TOOLTIP": "Recommencer le tutoriel d'intégration",
"HINT_SELECT_VIDEO": "Sélectionnez votre vidéo ici !"
}
-204
View File
@@ -1,204 +0,0 @@
# KoalaSync Browser Extension v2.0 - i18n Technical Implementation Plan
Welcome, future Antigravity AI agent! This document is placed directly in the codebase at `/extension/locales/i18n_plan.md` to serve as a comprehensive architectural handbook for the next steps in adding **full internationalization (i18n) support to the browser extension itself** while maintaining 100% video-sync and background communication safety.
---
## 🔍 Context & Audited Scope
KoalaSync is a lightweight, premium browser extension (Chrome & Firefox Manifest V3) for synchronized video playback. The landing pages are already compiled dynamically in 6 languages:
* English (`en`)
* German (`de`)
* French (`fr`)
* Spanish (`es`)
* Portuguese (Brazil) (`pt-BR`)
* Russian (`ru`)
Our goal is to build an identical, premium translation engine for the extension itself.
---
## 🏛️ Architectural Choice: Custom JSON Dictionary Engine (Approach B)
We evaluated two paths:
1. **Approach A (Native `chrome.i18n` API):** Uses `_locales/` directory. Too rigid—cannot support real-time dynamic switching inside the extension Settings dropdown without closing/re-opening the popup.
2. **Approach B (Custom Unified JSON Engine):** Uses flat JSON files matching our website files (`"KEY": "Value"`). Dynamically merges the target dictionary with baseline English `en.json` at runtime, programmatically providing an **airtight English fallback** and **real-time DOM translations** without popup reload.
We chose **Approach B** for maximum compatibility, premium real-time toggling, and clean fallback safety.
---
## 🔄 Resolve, Load, & State Flow
1. **On launch:** Look for saved language in `chrome.storage.sync.get('locale')`.
2. **Fallback Autodetect:** If no saved language, read `navigator.language` or `chrome.i18n.getUILanguage()`.
* If the detected locale is supported, set as active.
* If not supported, default to English (`en`).
3. **Dictionary Resolution:**
* Asynchronously load the English baseline dictionary (`/extension/locales/en.json`).
* If the target language is different, load target JSON (e.g. `/extension/locales/de.json`) and execute `Object.assign({}, enDict, targetDict)`. This guarantees dynamic translation while cleanly falling back to English for any missing keys.
4. **DOM Replacements:** Scan for `data-i18n`, `data-i18n-title`, and `data-i18n-placeholder` attributes, and translate them on the fly.
5. **Persistence:** Save dynamic selection modifications from the dropdown into `chrome.storage.sync`. Trigger instant DOM re-translation on change.
---
## 📂 Proposed File Structure
```
KoalaPlay/
└── extension/
├── locales/ # [NEW] Contains flat translation maps
│ ├── i18n_plan.md # This roadmap file
│ ├── en.json # Flat English baseline keys
│ ├── de.json # German keys
│ ├── fr.json # French keys
│ ├── es.json # Spanish keys
│ ├── pt-BR.json # Portuguese (Brasil) keys
│ └── ru.json # Russian keys
├── i18n.js # [NEW] ESM translation engine module
├── popup.html # Modified with data-i18n attributes
├── popup.js # Modified to initialize locales and update variables
└── background.js # Modified to push localized notification alerts
```
---
## 🛠️ Draft Code Snippets
### 1. `i18n.js` (Zero-Dependency Engine Module)
```javascript
// extension/i18n.js
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
export const DEFAULT_LANGUAGE = 'en';
let activeDictionary = {};
export async function loadLocale(langCode) {
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
try {
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
const enDict = await enResponse.json();
if (resolvedLang === DEFAULT_LANGUAGE) {
activeDictionary = enDict;
return;
}
const targetResponse = await fetch(chrome.runtime.getURL(`locales/${resolvedLang}.json`));
const targetDict = await targetResponse.json();
activeDictionary = Object.assign({}, enDict, targetDict);
} catch (err) {
console.error('[i18n] Failed to load dictionary. Falling back to English:', err);
const rescue = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
activeDictionary = await rescue.json();
}
}
export function getMessage(key) {
return activeDictionary[key] || key;
}
export function translateDOM() {
// Translate text nodes
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const translated = getMessage(key);
const img = el.querySelector('img');
if (img) {
el.innerHTML = '';
el.appendChild(img);
el.appendChild(document.createTextNode(' ' + translated));
} else {
el.textContent = translated;
}
});
// Translate tooltips
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.setAttribute('title', getMessage(key));
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.setAttribute('placeholder', getMessage(key));
});
}
```
### 2. Markup Changes (`popup.html`)
* Annotate text elements: `<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP">Settings</button>`
* Add Language Dropdown in the Settings Panel:
```html
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;" data-i18n="LABEL_LANGUAGE" title="Choose your preferred extension language">App Language</label>
<select id="langSelector" style="width: 150px; padding: 6px 10px; font-size: 13px; cursor: pointer;">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="pt-BR">Português (Brasil)</option>
<option value="ru">Русский</option>
</select>
</div>
```
### 3. Dynamic Script Wiring (`popup.js`)
* Import our engine: `import { loadLocale, translateDOM, getMessage } from './i18n.js';`
* Initialize during startup:
```javascript
async function init() {
const data = await chrome.storage.sync.get(['locale', ...]);
let activeLang = data.locale;
if (!activeLang) {
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
chrome.storage.sync.set({ locale: activeLang });
}
await loadLocale(activeLang);
translateDOM();
// Select matching option in selector
const langSelector = document.getElementById('langSelector');
if (langSelector) langSelector.value = activeLang;
// rest of standard init...
}
```
* Listen to Settings change event:
```javascript
const langSelector = document.getElementById('langSelector');
if (langSelector) {
langSelector.addEventListener('change', async () => {
const selectedLang = langSelector.value;
await chrome.storage.sync.set({ locale: selectedLang });
await loadLocale(selectedLang);
translateDOM();
// Re-render empty elements and tab list using new dynamic strings
refreshLogs();
refreshHistory();
populateTabs();
});
}
```
### 4. Background Notifications (`background.js`)
* Notifications inside `showNotification` should fetch dynamic keys based on `chrome.storage.sync` language settings and build clean alerts dynamically using `chrome.storage.sync.get('locale')`.
* Ensure that WebSocket event transmissions and video player scripts in `content.js` remain **completely untouched and unaware** of the localization engine.
---
## 🔒 Safety Guardrail
* **WebSocket Engine Integrity:** Under no circumstances should `background.js` Socket.IO handshakes, rate limits, or binary protocol headers be adjusted. i18n is strictly an interface rendering skin layer.
* **Player Control Pipeline Integrity:** `content.js` expectedEvent suppression tables, HLS buffer calculations, and Media Session API listeners must remain untouched. Do not bind any i18n logic into the content script's active tracking loop.
---
## 🧪 Verification Tasks
1. **Autodetection Audit:** Force-load the extension in Chrome/Firefox in a non-English language container and ensure it starts in that language or English gracefully.
2. **Integrity Validation Script:** Write a sanity node checker script `/scripts/test-locales.js` to ensure all key structures are uniform across all JSON files.
3. **Real-Time Refresh Verification:** Change dynamic drop-down preferences and verify that every element and alert swaps languages cleanly with zero redraw glitches or page crashes.
+106 -103
View File
@@ -3,8 +3,8 @@
"HTML_CLASS": "lang-it",
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Stanza",
"TAB_ROOM_TOOLTIP": "Impostazioni della stanza e connessione",
"TAB_SYNC": "Sincronizza",
"TAB_ROOM_TOOLTIP": "Impostazioni della stanza y connessione",
"TAB_SYNC": "Sincro",
"TAB_SYNC_TOOLTIP": "Controlli di sincronizzazione video e azioni remote",
"TAB_SETTINGS": "Impostazioni",
"TAB_SETTINGS_TOOLTIP": "Preferenze dell'estensione",
@@ -16,155 +16,155 @@
"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",
"BTN_SERVER_CUSTOM_TOOLTIP": "Connettiti al tuo server privato",
"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",
"LABEL_ROOM_ID_TOOLTIP": "L'identificatore univoco per la tua stanza",
"PLACEHOLDER_ROOM_ID": "Inserisci ID Stanza",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID univoco della stanza a cui vuoi unirti",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID della stanza a cui vuoi unirti",
"LABEL_PASSWORD": "Password (Opzionale)",
"LABEL_PASSWORD_TOOLTIP": "Password opzionale per limitare l'accesso alla stanza",
"LABEL_PASSWORD_TOOLTIP": "Password per limitare l'accesso alla stanza",
"PLACEHOLDER_PASSWORD": "Password della stanza (opzionale)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password per la stanza (lascia vuoto se nessuna)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Lascia vuoto se non c'è una password",
"BTN_JOIN_ROOM": "Entra / Crea Stanza",
"BTN_JOIN_ROOM_TOOLTIP": "Connettiti alla stanza",
"BTN_JOIN_ROOM_TOOLTIP": "Entra nella stanza",
"LABEL_PUBLIC_ROOMS": "Stanze Pubbliche",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubblicamente disponibili su questo server",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Elenco delle stanze pubbliche su questo server",
"BTN_REFRESH": "AGGIORNA",
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze pubbliche",
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze",
"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.",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Riprova tra {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento in corso. Disponibile tra {seconds}s.",
"LABEL_ACTIVE_ROOM": "Stanza Attiva",
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei attualmente connesso",
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei 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",
"LABEL_INVITE_LINK_TOOLTIP": "Condividi questo link per invitare i tuoi amici",
"LABEL_PEERS_IN_ROOM": "Partecipanti",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Utenti 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",
"LABEL_SELECT_VIDEO_TOOLTIP": "Scegli la scheda con il video da sincronizzare",
"OPTION_SELECT_TAB": "-- Seleziona una Scheda --",
"LABEL_REMOTE_CONTROL": "Controllo Remoto",
"LABEL_REMOTE_CONTROL": "Telecomando",
"BTN_COPY_INVITE": "📋 Link di Invito",
"BTN_COPY_INVITE_TOOLTIP": "Copia Link di Invito",
"BTN_COPY_INVITE_TOOLTIP": "Copia il link di invito",
"BTN_PLAY": "▶ Riproduci",
"BTN_PLAY_TOOLTIP": "Invia un comando di riproduzione a tutti",
"BTN_PLAY_TOOLTIP": "Avvia la riproduzione per 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",
"BTN_PAUSE_TOOLTIP": "Metti in pausa per tutti",
"BTN_SYNC": "⚡ SINCRO",
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione per tutti",
"OPTION_JUMP_TO_OTHERS": "Vai alla posizione degli altri",
"OPTION_JUMP_TO_ME": "Portali alla mia posizione",
"OPTION_JUMP_MODE_TOOLTIP": "Scegli l'obiettivo di sincronizzazione",
"LABEL_LAST_ACTIVITY": "Ultima Attività",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra l'ultimo comando inviato",
"NO_RECENT_COMMANDS": "Nessun comando recente",
"LOBBY_HEADER": "LOBBY EPISODIO",
"LOBBY_HEADER": "SALA D'ATTESA 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_SKIP_PLAY": "Salta e riproduci ora",
"BTN_SKIP_PLAY_TOOLTIP": "Annulla l'attesa e riproduci comunque",
"LOBBY_CONNECT_FIRST": "Entra prima in una stanza",
"LOBBY_CONNECT_FIRST_DESC": "Devi unirti a una stanza 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",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crea una stanza casuale ed entra",
"LABEL_USERNAME": "Tuo Nome Utente",
"LABEL_USERNAME_TOOLTIP": "Il nome utente aiuta gli altri a identificarti.",
"LABEL_USERNAME_TOOLTIP": "Il tuo nome visibile agli altri.",
"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_HIDE_CLUTTER": "Nascondi schede senza video",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Mantiene pulito l'elenco filtrando le schede non pertinenti",
"LABEL_AUTO_SYNC_NEXT": "Sincro auto prossimo episodio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Attende tutti i partecipanti prima di avviare il prossimo episodio.",
"LABEL_AUTO_COPY_INVITE": "Copia automatica invito",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia il link 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_NOTIFICATIONS_TOOLTIP": "Mostra notifiche quando qualcuno entra, esce o cambia stato.",
"LABEL_LANGUAGE": "Lingua Applicazione",
"LABEL_LANGUAGE_TOOLTIP": "Scegli la lingua preferita per l'estensione",
"LABEL_LANGUAGE_TOOLTIP": "Scegli la lingua dell'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",
"BTN_REGEN_ID": "Rigenera ID Utente",
"BTN_REGEN_ID_TOOLTIP": "Genera un nuovo ID interno e riconnettiti",
"REGEN_ID_DESC": "Usa questa opzione se riscontri errori di 'Identità Duplicata'.",
"REGEN_ID_OTHER_ISSUE": "Hai altri problemi? Apri una segnalazione su GitHub",
"LABEL_CONN_STATUS": "Stato Connessione",
"LABEL_CONN_STATUS_TOOLTIP": "Stato corrente della connessione WebSocket",
"LABEL_CONN_STATUS_TOOLTIP": "Stato attuale della connessione",
"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",
"BTN_COPY_LOGS_TOOLTIP": "Copia i log negli appunti",
"LABEL_VIDEO_DEBUG": "Debug Video",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Dettagli tecnici sul video 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",
"LABEL_HISTORY": "Cronologia Completa",
"LABEL_HISTORY_TOOLTIP": "Registro di tutti i comandi inviati nella stanza",
"HISTORY_EMPTY": "Ancora nessuna attività",
"LABEL_LOGS": "Log (Ultimi 50)",
"LABEL_LOGS_TOOLTIP": "Log di connessione tecnica per il debug",
"LABEL_LOGS_TOOLTIP": "Log tecnici per il debug",
"BTN_CLEAR": "PULISCI",
"BTN_CLEAR_TOOLTIP": "Cancella l'output del log",
"BTN_CLEAR_TOOLTIP": "Cancella i 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_1_TEXT": "Guarda i video insieme ai tuoi amici in perfetta sincronia. Facciamo un breve 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_2_TEXT": "Inizia da qui. Crea una stanza e condividi il link 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_3_TEXT": "Scegli il video che vuoi sincronizzare. Play, pausa o salta: tutti vedranno lo stesso.",
"ONBOARDING_4_TITLE": "3. Personalizza",
"ONBOARDING_4_TEXT": "Scegli un nome utente divertente così i tuoi amici sanno chi sei.",
"ONBOARDING_4_TEXT": "Scegli un nome utente per farti riconoscere dai tuoi amici.",
"ONBOARDING_5_TITLE": "Tutto pronto!",
"ONBOARDING_5_TEXT": "È ora di prendere i popcorn. Buona visione insieme!",
"ONBOARDING_5_TEXT": "È ora di prendere i popcorn. Buona visione!",
"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_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento.",
"ERR_NO_PEERS_TIME": "Nessun altro partecipante con posizione nota. Passa a 'Portali alla mia posizione'.",
"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_LOBBY_SKIPPED": "Sala d'attesa saltata.",
"TOAST_LOBBY_SKIP_FAILED": "Impossibile saltare la sala d'attesa.",
"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}",
"TOAST_PEER_ACTION": "{name} {action}",
"STATUS_CONNECTED": "Connesso",
"STATUS_RECONNECTING": "Riconnessione...",
"STATUS_CONNECTING": "Connessione in corso...",
"STATUS_FAILED": "Fallito",
"STATUS_FAILED": "Errore",
"STATUS_DISCONNECTED": "Disconnesso",
"BTN_STATE_JOINING": "🚀 Entrando...",
"BTN_STATE_RECONNECTING": "🔄 Riconnessione...",
"BTN_STATE_PLAYING": "▶ Riproduzione...",
"BTN_STATE_PLAYING": "▶ In riproduzione...",
"BTN_STATE_PAUSING": "⏸ In pausa...",
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione al gruppo ({time})...",
"BTN_STATE_SYNCING_GROUP": "Sincronizzazione di 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",
"NOTIF_PLAY": "ha avviato la riproduzione",
"NOTIF_PAUSE": "ha messo in pausa",
"NOTIF_SEEK": "ha cambiato posizione",
"NOTIF_FORCE_PREPARE": "ha avviato una sincronizzazione forzata",
"NOTIF_FORCE_EXECUTE": "ha sincronizzato tutti",
"DEBUG_NO_TAB": "Nessuna scheda selezionata.",
"DEBUG_COMM_FAIL": "Errore di comunicazione con il video.",
"EMPTY_PEERS_TITLE": "Nessun partecipante",
"EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare",
"EMPTY_HISTORY_TITLE": "Nessuna attività",
"EMPTY_HISTORY_HINT": "Usa i comandi per vedere la cronologia",
"EMPTY_LOGS_TITLE": "Nessun log",
"EMPTY_LOGS_HINT": "Gli eventi di connessione appariranno qui",
"EMPTY_LOGS_HINT": "Gli eventi appariranno qui",
"EMPTY_ROOMS_TITLE": "Nessuna stanza attiva",
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna per trovare quelle pubbliche",
"EMPTY_ROOMS_HINT": "Crea una stanza o aggiorna l'elenco",
"LABEL_YOU": "Tu",
"ONBOARDING_DONE": "Fatto!",
"LABEL_LOBBY_PEER_READY": "Pronto",
@@ -173,37 +173,40 @@
"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",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincro Episodio Fallita",
"NOTIF_LOBBY_CANCEL_MSG": "Sincro auto annullata: {reason}. Usa la sincronizzazione manuale.",
"LOBBY_CANCEL_TIMEOUT": "Tempo scaduto",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo scaduto (ripristinato)",
"LOBBY_CANCEL_PEERS_LEFT": "Tutti gli altri partecipanti sono usciti",
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo scaduto: caricamento incompleto per alcuni partecipanti",
"LOBBY_CANCEL_USER": "Annullato dall'utente",
"NOTIF_ERROR_TITLE": "Errore KoalaSync",
"FOOTER_SUPPORT": "☕ Offrimi un caffè",
"FOOTER_REVIEW": "★ Valuta",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Valutaci",
"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",
"LABEL_AUDIO_PROCESSING": "Elaborazione Audio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti sonori (come il compressore) ai video",
"AUDIO_OPEN_SETTINGS": "Apri",
"NEW_FEATURE_AUDIO": "Novità: Elaborazione audio — prova il compressore!",
"NEW_FEATURE_AUDIO": "Novità: Elaborazione Audio — prova il compressore!",
"AUDIO_BACK": "← Indietro",
"AUDIO_PAGE_TITLE": "Impostazioni audio",
"AUDIO_MASTER_TOGGLE": "Elaborazione audio",
"AUDIO_PAGE_TITLE": "Impostazioni Audio",
"AUDIO_MASTER_TOGGLE": "Elaborazione Audio",
"AUDIO_COMPRESSOR": "Compressore",
"AUDIO_COMPRESSOR_ENABLE": "Attivato",
"AUDIO_COMPRESSOR_ENABLE": "Attivo",
"AUDIO_PRESET": "Preimpostazione",
"AUDIO_PRESET_RECOMMENDED": "Consigliato",
"AUDIO_PRESET_DYNAMIC_RANGE": "Gamma dinamica",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Miglioramento vocale",
"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_PARAM_THRESHOLD": "Soglia (Threshold)",
"AUDIO_PARAM_KNEE": "Ginocchio (Knee)",
"AUDIO_PARAM_RATIO": "Rapporto (Ratio)",
"AUDIO_PARAM_ATTACK": "Attacco (Attack)",
"AUDIO_PARAM_RELEASE": "Rilascio (Release)",
"AUDIO_EQUALIZER": "Equalizzatore",
"AUDIO_COMING_SOON": "Prossimamente"
"AUDIO_COMING_SOON": "Prossimamente",
"BTN_RESTART_TOUR": "Riavvia Tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Riavvia il tutorial introduttivo",
"HINT_SELECT_VIDEO": "Scegli il tuo video qui!"
}
+6 -3
View File
@@ -29,7 +29,7 @@
"BTN_JOIN_ROOM": "ルームに参加 / 作成",
"BTN_JOIN_ROOM_TOOLTIP": "ルームに接続",
"LABEL_PUBLIC_ROOMS": "公開ルーム",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "このサーバー上の公開ルームリスト",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "このサーバー上の公開ルームリスト",
"BTN_REFRESH": "更新",
"BTN_REFRESH_TOOLTIP": "公開ルームのリストを更新",
"PUBLIC_ROOMS_REFRESHING": "更新中...",
@@ -181,7 +181,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "タイムアウト — 一部のメンバーがエピソードを読み込めませんでした",
"LOBBY_CANCEL_USER": "ユーザーによってキャンセルされました",
"NOTIF_ERROR_TITLE": "KoalaSyncエラー",
"FOOTER_SUPPORT": "☕ コーヒーをおごってね",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ 評価する",
"FOOTER_SUPPORT_PROMPT": "KoalaSyncはいかが?レビューを書いてください!",
"LABEL_AUDIO_PROCESSING": "オーディオ処理",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "イコライザー",
"AUDIO_COMING_SOON": "近日公開"
"AUDIO_COMING_SOON": "近日公開",
"BTN_RESTART_TOUR": "チュートリアルを再起動",
"BTN_RESTART_TOUR_TOOLTIP": "オンボーディングチュートリアルを再起動する",
"HINT_SELECT_VIDEO": "ここで動画を選択してください!"
}
+7 -4
View File
@@ -30,7 +30,7 @@
"BTN_JOIN_ROOM_TOOLTIP": "방에 연결",
"LABEL_PUBLIC_ROOMS": "공개 방",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "이 서버의 공개 방 목록",
"BTN_REFRESH": "Refreschi",
"BTN_REFRESH": "새로고침",
"BTN_REFRESH_TOOLTIP": "공개 방 목록 새로고침",
"PUBLIC_ROOMS_REFRESHING": "새로고침 중...",
"BTN_REFRESH_COOLDOWN": "{seconds}초 대기",
@@ -140,7 +140,7 @@
"TOAST_PEER_ACTION": "{name} 님이 {action}",
"STATUS_CONNECTED": "연결됨",
"STATUS_RECONNECTING": "재연결 중...",
"STATUS_CONNECTING": "연kel 중...",
"STATUS_CONNECTING": "연 중...",
"STATUS_FAILED": "실패",
"STATUS_DISCONNECTED": "연결 끊김",
"BTN_STATE_JOINING": "🚀 참여 중...",
@@ -181,7 +181,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "시간 초과 — 모든 피어가 에피소드를 로드하지 못했습니다",
"LOBBY_CANCEL_USER": "사용자에 의해 취소됨",
"NOTIF_ERROR_TITLE": "KoalaSync 오류",
"FOOTER_SUPPORT": "☕ 커피 한 잔 사주세요",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ 평가하기",
"FOOTER_SUPPORT_PROMPT": "KoalaSync가 마음에 드세요? 리뷰를 남겨주세요!",
"LABEL_AUDIO_PROCESSING": "오디오 처리",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "이퀄라이저",
"AUDIO_COMING_SOON": "출시 예정"
"AUDIO_COMING_SOON": "출시 예정",
"BTN_RESTART_TOUR": "튜토리얼 다시 시작",
"BTN_RESTART_TOUR_TOOLTIP": "온보딩 튜토리얼을 다시 시작합니다",
"HINT_SELECT_VIDEO": "여기에서 비디오를 선택하세요!"
}
+6 -3
View File
@@ -156,7 +156,7 @@
"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.",
"DEBUG_COMM_FAIL": "Kon niet communiceren met de videotab.",
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
"EMPTY_HISTORY_TITLE": "Nog geen activiteit",
@@ -181,7 +181,7 @@
"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_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Beoordelen",
"FOOTER_SUPPORT_PROMPT": "Vind je KoalaSync leuk? Laat een review achter!",
"LABEL_AUDIO_PROCESSING": "Audioverwerking",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Equalizer",
"AUDIO_COMING_SOON": "Binnenkort beschikbaar"
"AUDIO_COMING_SOON": "Binnenkort beschikbaar",
"BTN_RESTART_TOUR": "Tutorial opnieuw starten",
"BTN_RESTART_TOUR_TOOLTIP": "Start de introductie-tutorial opnieuw",
"HINT_SELECT_VIDEO": "Selecteer hier je video!"
}
+5 -2
View File
@@ -181,7 +181,7 @@
"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_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Oceń",
"FOOTER_SUPPORT_PROMPT": "Podoba Ci się KoalaSync? Zostaw recenzję!",
"LABEL_AUDIO_PROCESSING": "Przetwarzanie dźwięku",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Korektor",
"AUDIO_COMING_SOON": "Wkrótce"
"AUDIO_COMING_SOON": "Wkrótce",
"BTN_RESTART_TOUR": "Uruchom samouczek ponownie",
"BTN_RESTART_TOUR_TOOLTIP": "Uruchom ponownie samouczek powitalny",
"HINT_SELECT_VIDEO": "Wybierz swoje wideo tutaj!"
}
+76 -73
View File
@@ -4,9 +4,9 @@
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Sala",
"TAB_ROOM_TOOLTIP": "Configurações da sala e conexão",
"TAB_SYNC": "Sincro",
"TAB_SYNC": "Sincronia",
"TAB_SYNC_TOOLTIP": "Controles de sincronia de vídeo e ações",
"TAB_SETTINGS": "Opções",
"TAB_SETTINGS": "Configurações",
"TAB_SETTINGS_TOOLTIP": "Preferências da extensão",
"TAB_STATUS": "Status",
"TAB_STATUS_TOOLTIP": "Diagnósticos avançados e logs",
@@ -15,23 +15,23 @@
"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",
"BTN_SERVER_CUSTOM": "Personalizado",
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectar ao seu próprio servidor privado",
"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",
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo da sua sala",
"PLACEHOLDER_ROOM_ID": "Digite o ID da sala",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala na qual você deseja entrar",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID 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)",
"PLACEHOLDER_PASSWORD": "Senha da sala",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Deixe em branco se não houver senha",
"BTN_JOIN_ROOM": "Entrar / Criar sala",
"BTN_JOIN_ROOM_TOOLTIP": "Conectar à sala",
"BTN_JOIN_ROOM_TOOLTIP": "Entrar na 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",
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas",
"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.",
@@ -41,99 +41,99 @@
"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",
"LABEL_INVITE_LINK_TOOLTIP": "Compartilhe este link com seus amigos para que eles entrem",
"LABEL_PEERS_IN_ROOM": "Participantes",
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros usuários conectados 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",
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolha a aba do navegador com 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_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
"BTN_PAUSE": "⏸ Pausar",
"BTN_PAUSE_TOOLTIP": "Enviar um comando de pausar para todos",
"BTN_SYNC": "⚡ SYNC",
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
"BTN_SYNC": "⚡ SINCRO",
"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_TO_OTHERS": "Ir para a posição dos outros",
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
"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",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausar ou pular",
"NO_RECENT_COMMANDS": "Sem comandos recentes",
"LOBBY_HEADER": "LOBBY DE EPISÓDIO",
"LOBBY_HEADER": "SALA DE ESPERA",
"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",
"BTN_SKIP_PLAY": "Pular e reproduzir agora",
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar a espera e reproduzir mesmo assim",
"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.",
"LABEL_USERNAME_TOOLTIP": "Seu nome visível para os outros.",
"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_HIDE_CLUTTER": "Ocultar abas sem vídeo",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra abas não relacionadas para manter a lista limpa",
"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_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda todos ao mudar de episódio.",
"LABEL_AUTO_COPY_INVITE": "Auto-copiar link de convite",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link 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_NOTIFICATIONS_TOOLTIP": "Mostra notificações quando alguém entra, sai, reproduz ou 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_LANGUAGE_TOOLTIP": "Escolha o seu idioma preferido",
"LABEL_TROUBLESHOOTING": "Solução de problemas",
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para corrigir problemas de conexão",
"BTN_REGEN_ID": "Regenerar ID do usuário",
"BTN_REGEN_ID_TOOLTIP": "Gerar um novo ID interno e conectar novamente",
"REGEN_ID_DESC": "Use esta opção se aparecer o erro de 'Identidade duplicada'.",
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um relatório 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": "TENTAR NOVAMENTE",
"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",
"BTN_COPY_LOGS_TOOLTIP": "Copiar logs para a área de transferência",
"LABEL_VIDEO_DEBUG": "Debug de vídeo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o vídeo selecionado no momento",
"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",
"LABEL_HISTORY_TOOLTIP": "Registro cronológico de todos os comandos 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",
"BTN_CLEAR_TOOLTIP": "Limpar os 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_1_TEXT": "Assista a vídeos com seus amigos 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_3_TEXT": "Navegue aqui para selecionar o vídeo a ser sincronizado. Reproduza, pause e avance — todos verão o mesmo.",
"ONBOARDING_4_TITLE": "3. Personalizar",
"ONBOARDING_4_TEXT": "Escolha um nome de usuário divertido para que seus amigos saibam quem você é.",
"ONBOARDING_4_TEXT": "Escolha um nome de usuário divertido para que seus amigos reconheçam 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_CONN_TIMEOUT": "Tempo limite 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_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento.",
"ERR_NO_PEERS_TIME": "Nenhum participante com posição conhecida. Mude para 'Trazer para a minha posição'.",
"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_LOBBY_SKIPPED": "Sala de espera ignorada.",
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar a sala de espera.",
"TOAST_LOGS_COPIED": "Copiado!",
"TOAST_PEER_JOINED": "{name} entrou na sala",
"TOAST_PEER_LEFT": "{name} saiu da sala",
@@ -146,46 +146,46 @@
"BTN_STATE_JOINING": "🚀 Entrando...",
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
"BTN_STATE_PLAYING": "▶ Reproduzindo...",
"BTN_STATE_PAUSING": "⏸ Pausando...",
"BTN_STATE_PAUSING": "⏸ Em pausa...",
"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_PAUSE": "pausou o vídeo",
"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",
"DEBUG_NO_TAB": "Nenhuma aba selecionada.",
"DEBUG_COMM_FAIL": "Erro ao se comunicar com o vídeo.",
"EMPTY_PEERS_TITLE": "Nenhum participante",
"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_HISTORY_TITLE": "Sem atividade",
"EMPTY_HISTORY_HINT": "Reproduza ou pause 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",
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize a lista",
"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",
"LABEL_CUSTOM_SERVER": "Servidor privado",
"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.",
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Você deve sincronizar manualmente.",
"LOBBY_CANCEL_TIMEOUT": "Tempo limite esgotado",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo limite esgotado (recuperado)",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo limite (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_TIMEOUT_PEERS_LOAD": "Tempo limite — nem todos carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo usuário",
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
"FOOTER_SUPPORT": "☕ Me pague um café",
"NOTIF_ERROR_TITLE": "Erro no KoalaSync",
"FOOTER_SUPPORT": "Support KoalaSync",
"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",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio (como compressão) aos vídeos",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
"AUDIO_BACK": "← Voltar",
@@ -196,14 +196,17 @@
"AUDIO_PRESET": "Predefinição",
"AUDIO_PRESET_RECOMMENDED": "Recomendado",
"AUDIO_PRESET_DYNAMIC_RANGE": "Faixa dinâmica",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Aprimoramento de voz",
"AUDIO_PRESET_VOCAL_ENHANCEMENT": "Aprimoramento 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_PARAM_THRESHOLD": "Limiar (Threshold)",
"AUDIO_PARAM_KNEE": "Joelho (Knee)",
"AUDIO_PARAM_RATIO": "Razão (Ratio)",
"AUDIO_PARAM_ATTACK": "Ataque (Attack)",
"AUDIO_PARAM_RELEASE": "Liberação (Release)",
"AUDIO_EQUALIZER": "Equalizador",
"AUDIO_COMING_SOON": "Em breve"
"AUDIO_COMING_SOON": "Em breve",
"BTN_RESTART_TOUR": "Reiniciar tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial de introdução",
"HINT_SELECT_VIDEO": "Selecione seu vídeo aqui!"
}
+102 -99
View File
@@ -4,136 +4,136 @@
"APP_TITLE": "KoalaSync",
"TAB_ROOM": "Sala",
"TAB_ROOM_TOOLTIP": "Definições da sala e ligação",
"TAB_SYNC": "Sincronização",
"TAB_SYNC": "Sincronia",
"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",
"TAB_STATUS_TOOLTIP": "Diagnóstico avançado 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",
"BTN_SERVER_CUSTOM_TOOLTIP": "Ligar ao seu próprio servidor privado",
"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",
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para a sua sala",
"PLACEHOLDER_ROOM_ID": "Introduzir ID da Sala",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala à qual deseja aderir",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID da sala à qual deseja aceder",
"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)",
"LABEL_PASSWORD_TOOLTIP": "Palavra-passe para restringir o acesso à sala",
"PLACEHOLDER_PASSWORD": "Palavra-passe da sala",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Deixe em branco se não houver palavra-passe",
"BTN_JOIN_ROOM": "Entrar / Criar Sala",
"BTN_JOIN_ROOM_TOOLTIP": "Ligar à sala",
"BTN_JOIN_ROOM_TOOLTIP": "Entrar na sala",
"LABEL_PUBLIC_ROOMS": "Salas Públicas",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponíveis neste servidor",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas neste servidor",
"BTN_REFRESH": "ATUALIZAR",
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas",
"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.",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A atualização está em espera. Tente novamente em {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "A atualizar salas. Próxima atualização 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_INVITE_LINK": "Link de Convite",
"LABEL_INVITE_LINK_TOOLTIP": "Partilhe 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_PEERS_IN_ROOM_TOOLTIP": "Utilizadores atualmente ligados a esta sala",
"NO_PEERS_CONNECTED": "Nenhum participante ligado",
"BTN_LEAVE_ROOM": "Sair da Sala",
"LABEL_SELECT_VIDEO": "Selecionar Vídeo",
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolher o separador do navegador que contém o vídeo a sincronizar",
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolha o separador do navegador com 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_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_PLAY_TOOLTIP": "Iniciar a reprodução 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",
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
"BTN_SYNC": "⚡ SINCRO",
"BTN_SYNC_TOOLTIP": "Forçar sincronização de todos os utilizadores",
"OPTION_JUMP_TO_OTHERS": "Ir para a posição dos outros",
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
"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",
"LABEL_LAST_ACTIVITY": "Última Atividade",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente",
"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_HEADER": "SALA DE ESPERA",
"LOBBY_WAITING_FOR": "🎬 À espera de: \"{title}\"",
"LOBBY_WAITING_PEERS": "À espera dos participantes...",
"BTN_SKIP_PLAY": "Ignorar e reproduzir agora",
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar a espera e reproduzir mesmo assim",
"LOBBY_CONNECT_FIRST": "Ligue-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",
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Criar uma 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.",
"LABEL_USERNAME_TOOLTIP": "O seu nome visível para os outros.",
"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_HIDE_CLUTTER": "Ocultar separadores sem vídeo",
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra separadores não relacionados para manter a lista limpa",
"LABEL_AUTO_SYNC_NEXT": "Sincro Auto do Próximo Episódio",
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda por todos ao mudar de episódio.",
"LABEL_AUTO_COPY_INVITE": "Copiar Convite Automaticamente",
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link 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_NOTIFICATIONS_TOOLTIP": "Mostra notificações quando alguém entra, sai, reproduz ou pausa.",
"LABEL_LANGUAGE": "Idioma da Aplicação",
"LABEL_LANGUAGE_TOOLTIP": "Escolha o seu idioma de preferência",
"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": "Regerar ID do Utilizador",
"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",
"REGEN_ID_DESC": "Use isto se encontrar erros de 'Identidade Duplicada'.",
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um relatório no GitHub",
"LABEL_CONN_STATUS": "Estado da Ligação",
"LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação WebSocket",
"LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação",
"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",
"BTN_COPY_LOGS_TOOLTIP": "Copiar registos para a área de transferência",
"LABEL_VIDEO_DEBUG": "Debug de Vídeo",
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o vídeo atualmente selecionado",
"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",
"LABEL_HISTORY": "Histórico Completo",
"LABEL_HISTORY_TOOLTIP": "Registo cronológico de todos os comandos na sala",
"HISTORY_EMPTY": "Nenhuma atividade ainda",
"LABEL_LOGS": "Registos (Últimos 50)",
"LABEL_LOGS_TOOLTIP": "Registos de ligação técnica para depuração",
"LABEL_LOGS_TOOLTIP": "Registos técnicos para depuração",
"BTN_CLEAR": "LIMPAR",
"BTN_CLEAR_TOOLTIP": "Limpar a saída do registo",
"BTN_CLEAR_TOOLTIP": "Limpar os registos",
"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_1_TEXT": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Vamos fazer 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_3_TEXT": "Navegue até aqui para selecionar o vídeo a sincronizar. Reproduza, pause e avance — todos vêm o mesmo.",
"ONBOARDING_4_TITLE": "3. Personalizar",
"ONBOARDING_4_TEXT": "Escolha um nome de utilizador divertido para que os seus amigos saibam quem você é.",
"ONBOARDING_4_TEXT": "Escolha um nome de utilizador divertido para que os seus amigos o reconheçam.",
"ONBOARDING_5_TITLE": "Está tudo pronto!",
"ONBOARDING_5_TEXT": "Altura de ir buscar pipocas. Divirtam-se a ver juntos!",
"ONBOARDING_5_TEXT": "Hora de ir buscar as pipocas. Divirtam-se!",
"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_INVALID_SERVER_URL": "Formato de URL do servidor inválido.",
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento.",
"ERR_NO_PEERS_TIME": "Nenhum participante com hora conhecida. Mude para 'Trazer para a minha posição'.",
"ERR_NO_VIDEO_TAB": "Não foi possível aceder 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_LOBBY_SKIPPED": "Sala de espera ignorada.",
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar a sala de espera.",
"TOAST_LOGS_COPIED": "Copiado!",
"TOAST_PEER_JOINED": "{name} entrou na sala",
"TOAST_PEER_LEFT": "{name} saiu da sala",
@@ -146,64 +146,67 @@
"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_PAUSING": "⏸ Em pausa...",
"BTN_STATE_SYNCING_GROUP": "A sincronizar em 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_PAUSE": "pausou o vídeo",
"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": "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",
"DEBUG_NO_TAB": "Nenhum separador selecionado.",
"DEBUG_COMM_FAIL": "Erro ao comunicar com o vídeo.",
"EMPTY_PEERS_TITLE": "Nenhum participante",
"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_HISTORY_TITLE": "Nenhuma atividade",
"EMPTY_HISTORY_HINT": "Reproduza ou pause para ver o histórico",
"EMPTY_LOGS_TITLE": "Sem registos",
"EMPTY_LOGS_HINT": "Os eventos 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",
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize a lista",
"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",
"LABEL_CUSTOM_SERVER": "Servidor Privado",
"BTN_STATE_CREATING": "🚀 A Criar Sala...",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Sincronização do Episódio Falhou",
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Falha na Sincronização do Episódio",
"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_TIMEOUT": "Tempo limite expirado",
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo limite (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_TIMEOUT_PEERS_LOAD": "Tempo limite — alguns participantes não carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo utilizador",
"NOTIF_ERROR_TITLE": "Erro do KoalaSync",
"FOOTER_SUPPORT": "☕ Oferece um café",
"NOTIF_ERROR_TITLE": "Erro no KoalaSync",
"FOOTER_SUPPORT": "Support KoalaSync",
"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",
"LABEL_AUDIO_PROCESSING": "Processamento de Áudio",
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio (como compressão) aos vídeos",
"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_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_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_PARAM_THRESHOLD": "Limiar (Threshold)",
"AUDIO_PARAM_KNEE": "Joelho (Knee)",
"AUDIO_PARAM_RATIO": "Rácio (Ratio)",
"AUDIO_PARAM_ATTACK": "Ataque (Attack)",
"AUDIO_PARAM_RELEASE": "Libertação (Release)",
"AUDIO_EQUALIZER": "Equalizador",
"AUDIO_COMING_SOON": "Em breve"
"AUDIO_COMING_SOON": "Em breve",
"BTN_RESTART_TOUR": "Reiniciar Tutorial",
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial introdutório",
"HINT_SELECT_VIDEO": "Selecione o seu vídeo aqui!"
}
+5 -2
View File
@@ -181,7 +181,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
"LOBBY_CANCEL_USER": "Отменено пользователем",
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync",
"FOOTER_SUPPORT": "☕ Угости кофе",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Оценить",
"FOOTER_SUPPORT_PROMPT": "Нравится KoalaSync? Оставьте отзыв!",
"LABEL_AUDIO_PROCESSING": "Обработка звука",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Эквалайзер",
"AUDIO_COMING_SOON": "Скоро"
"AUDIO_COMING_SOON": "Скоро",
"BTN_RESTART_TOUR": "Перезапустить обучение",
"BTN_RESTART_TOUR_TOOLTIP": "Запустить приветственное руководство заново",
"HINT_SELECT_VIDEO": "Выберите ваше видео здесь!"
}
+5 -2
View File
@@ -181,7 +181,7 @@
"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_SUPPORT": "Support KoalaSync",
"FOOTER_REVIEW": "★ Değerlendir",
"FOOTER_SUPPORT_PROMPT": "KoalaSync'i beğendin mi? Bir yorum bırak!",
"LABEL_AUDIO_PROCESSING": "Ses işleme",
@@ -205,5 +205,8 @@
"AUDIO_PARAM_ATTACK": "Attack",
"AUDIO_PARAM_RELEASE": "Release",
"AUDIO_EQUALIZER": "Ekolayzer",
"AUDIO_COMING_SOON": "Çok yakında"
"AUDIO_COMING_SOON": "Çok yakında",
"BTN_RESTART_TOUR": "Eğitimi Yeniden Başlat",
"BTN_RESTART_TOUR_TOOLTIP": "Tanıtım eğitimini yeniden başlat",
"HINT_SELECT_VIDEO": "Videonuzu buradan seçin!"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "uk",
"HTML_CLASS": "lang-uk",
"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": "Введіть Room ID",
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Унікальний ідентифікатор кімнати, до якої ви хочете приєднатися",
"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}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Оновлення списку кімнат охолоджується. Повторіть спробу в {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Оновлення громадських кімнат. Наступне оновлення доступне в {seconds}s.",
"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": "Надішліть усім команду Play",
"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": "Інша проблема? Відкрийте випуск 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": "Support KoalaSync",
"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": "Коліно",
"AUDIO_PARAM_RATIO": "Співвідношення",
"AUDIO_PARAM_ATTACK": "Атака",
"AUDIO_PARAM_RELEASE": "Спад",
"AUDIO_EQUALIZER": "Еквалайзер",
"AUDIO_COMING_SOON": "Скоро буде",
"BTN_RESTART_TOUR": "Перезапустіть підручник",
"BTN_RESTART_TOUR_TOOLTIP": "Перезапустіть навчальний посібник із адаптації",
"HINT_SELECT_VIDEO": "Виберіть своє відео тут!"
}
+212
View File
@@ -0,0 +1,212 @@
{
"LANG_CODE": "zh",
"HTML_CLASS": "lang-zh",
"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": "输入Room 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": "重新生成您的内部 ID 并重新连接",
"REGEN_ID_DESC": "如果您看到“重复身份”错误,请使用此选项。",
"REGEN_ID_OTHER_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": "Support KoalaSync",
"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": "拐点",
"AUDIO_PARAM_RATIO": "比率",
"AUDIO_PARAM_ATTACK": "起音",
"AUDIO_PARAM_RELEASE": "释放",
"AUDIO_EQUALIZER": "均衡器",
"AUDIO_COMING_SOON": "即将推出",
"BTN_RESTART_TOUR": "重启教程",
"BTN_RESTART_TOUR_TOOLTIP": "重新启动入门教程",
"HINT_SELECT_VIDEO": "在这里选择您的视频!"
}
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.2.4",
"version": "2.4.4",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
+94
View File
@@ -0,0 +1,94 @@
export function initTabManager({
getCurrentTabId,
setCurrentTabId,
setCurrentTabTitle,
setLastContentHeartbeatAt,
setRoomIdleSince,
getCurrentRoom,
getPeerId,
getStorageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
ensureState,
EVENTS
}) {
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
await ensureState();
const tabId = getCurrentTabId();
if (!tabId) return;
chrome.tabs.sendMessage(tabId, {
action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue
}).catch(() => {});
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
if (tabId === getCurrentTabId()) {
const wasInRoom = !!getCurrentRoom();
setCurrentTabId(null);
setCurrentTabTitle(null);
setLastContentHeartbeatAt(null);
const now = Date.now();
setRoomIdleSince(now);
chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince: now,
lastContentHeartbeatAt: null
});
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
if (wasInRoom) {
const roomAtClose = getCurrentRoom();
getSettings().then(settings => {
if (getCurrentRoom() !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId: getPeerId(),
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
const room = getCurrentRoom();
if (room && Array.isArray(room.peers)) {
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (getStorageInitialized()) {
chrome.storage.session.set({ currentRoom: room });
}
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
await ensureState();
const curTabId = getCurrentTabId();
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
})
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
}
});
}
+44 -13
View File
@@ -364,6 +364,14 @@
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
@keyframes spotlightPulse {
0%, 100% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 0px var(--accent); }
50% { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.65), 0 0 0 6px var(--accent); }
}
@keyframes floatHint {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
</style>
</head>
<body>
@@ -457,11 +465,16 @@
<div id="tab-sync" class="tab-content">
<!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active">
<div class="form-group">
<div class="form-group" style="position: relative;">
<label title="Choose the browser tab containing the video to sync" data-i18n="LABEL_SELECT_VIDEO" data-i18n-title="LABEL_SELECT_VIDEO_TOOLTIP">Select Video</label>
<select id="targetTab">
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
</select>
<!-- Hint Tooltip -->
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: white; padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
Select your video here!
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
@@ -571,13 +584,16 @@
<option value="nl">🇳🇱 Nederlands</option>
<option value="ja">🇯🇵 日本語</option>
<option value="ko">🇰🇷 한국어</option>
<option value="zh">🇨🇳 中文</option>
<option value="uk">🇺🇦 Українська</option>
<option value="pt">🇵🇹 Português (Portugal)</option>
</select>
</div>
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
<label title="Tools for fixing connection issues" data-i18n="LABEL_TROUBLESHOOTING" data-i18n-title="LABEL_TROUBLESHOOTING_TOOLTIP">Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
<button id="restartTourBtn" class="secondary" style="width: 100%; font-size: 11px;" title="Restart the onboarding tutorial" data-i18n="BTN_RESTART_TOUR" data-i18n-title="BTN_RESTART_TOUR_TOOLTIP">Restart Tutorial</button>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px; margin-top: 8px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;" data-i18n="REGEN_ID_DESC">Use this if you see "Duplicate Identity" errors.</p>
<p style="font-size: 9px; text-align: center; margin-top: 6px;"><a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" style="color: var(--accent); text-decoration: none;" data-i18n="REGEN_ID_OTHER_ISSUE">Other issue? Open a GitHub Issue</a></p>
</div>
@@ -586,7 +602,7 @@
<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>
<a id="settingsSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">Support KoalaSync</a>
<span> · </span>
<span id="settingsVersion">v0.0.0</span>
</div>
@@ -627,7 +643,7 @@
<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>
<a id="devSupportLink" href="#" target="_blank" data-i18n="FOOTER_SUPPORT">Support KoalaSync</a>
<span> · </span>
<span id="appVersion">v0.0.0</span>
</div>
@@ -637,16 +653,31 @@
<script src="popup.js" type="module"></script>
<!-- Onboarding Overlay -->
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter: blur(2px); z-index:1000; align-items:flex-end; justify-content:center; padding-bottom: 20px;">
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; width:90%; text-align:center; box-shadow:0 8px 32px rgba(0,0,0,0.5); ">
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">👋</div>
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 8px; font-size:16px;">Welcome to KoalaSync!</h2>
<p id="onboarding-text" style="color:var(--text-muted); font-size:13px; margin:0 0 16px; line-height:1.4;">Let's get you started.</p>
<div style="display:flex; gap:8px; justify-content:center;">
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:transparent; z-index:1000; pointer-events:auto;">
<!-- Spotlight element -->
<div id="onboarding-spotlight" style="position:fixed; pointer-events:none; border:2px solid var(--accent); box-shadow:0 0 0 9999px rgba(15, 23, 42, 0.65); border-radius:8px; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display:none; opacity:0; z-index:1001; animation: spotlightPulse 2s infinite ease-in-out;"></div>
<!-- Onboarding Card -->
<div id="onboarding-card" style="position:fixed; background:var(--card); padding:16px 20px; border-radius:12px; width:280px; box-shadow:0 8px 32px rgba(0,0,0,0.5); z-index:1002; transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-sizing:border-box; border:1px solid #334155; opacity:0; transform: scale(0.9); display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span id="onboarding-icon" style="font-size:24px;">👋</span>
<span id="onboarding-step-indicator" style="font-size:10px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:0.5px;">Step 1 of 5</span>
</div>
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 6px; font-size:14px; font-weight:700; text-align:left;">Welcome to KoalaSync!</h2>
<p id="onboarding-text" style="color:var(--text-muted); font-size:12px; margin:0 0 16px; line-height:1.4; text-align:left;">Let's get you started.</p>
<!-- Progress Bar -->
<div id="onboarding-progress-container" style="height:4px; background:#334155; border-radius:2px; margin-bottom:16px; overflow:hidden;">
<div id="onboarding-progress-bar" style="height:100%; width:20%; background:var(--accent); transition:width 0.3s ease;"></div>
</div>
<div style="display:flex; gap:8px; justify-content:space-between; align-items:center;">
<button id="onboarding-skip" class="secondary" style="width:auto; padding:6px 12px; margin:0; font-size:11px;" title="Skip the tutorial" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:6px 16px; margin:0; font-size:11px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
</div>
<!-- Tooltip pointer arrow -->
<div id="onboarding-arrow" style="position:absolute; width:0; height:0; border:6px solid transparent; transition:all 0.3s ease;"></div>
</div>
</div>
</body>
+300 -46
View File
@@ -29,6 +29,7 @@ const elements = {
inviteLink: document.getElementById('inviteLink'),
filterNoise: document.getElementById('filterNoise'),
regenId: document.getElementById('regenId'),
restartTourBtn: document.getElementById('restartTourBtn'),
lastActionCard: document.getElementById('lastActionCard'),
historyList: document.getElementById('historyList'),
copyLogs: document.getElementById('copyLogs'),
@@ -68,6 +69,7 @@ let lastPeersJson = null;
let lastKnownPeers = [];
let isDevTabVisible = false;
let reconnectSlowMode = false;
let isProcessingConnection = false;
let joinBtnTimeout = null;
let forceSyncResetTimer = null;
let popupIntervals = [];
@@ -173,19 +175,9 @@ function setRoomRefreshCooldown() {
// --- Initialization ---
async function init() {
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
// Migrate preferences from sync → local for existing users
const oldSync = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']);
const toMigrate = {};
for (const key of ['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings']) {
if (localData[key] === undefined && oldSync[key] !== undefined) {
toMigrate[key] = oldSync[key];
localData[key] = oldSync[key];
}
}
if (Object.keys(toMigrate).length) {
await chrome.storage.local.set(toMigrate);
}
// Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there).
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -235,8 +227,7 @@ async function init() {
refreshLogs();
refreshHistory();
// Default connection status (localized) before async check
applyConnectionStatus('disconnected');
// Initial Status Check (status shows via GET_STATUS below)
// Initial Status Check
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
@@ -253,14 +244,34 @@ async function init() {
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
// If user has a room configured but background is not connected,
// trigger connection now — the popup opening is explicit user intent.
if (res.status === 'disconnected' && localData.roomId) {
chrome.runtime.sendMessage({ type: 'CONNECT' }).catch(() => {});
applyConnectionStatus('connecting');
}
// Populate Tabs using the background's targetTabId
await populateTabs(res.peers, res.targetTabId);
// Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
if (res.status === 'connected' && !res.targetTabId && localData.roomId) {
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
} else if (localData.activeTab) {
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
if (btn) btn.click();
}
} else {
await populateTabs();
if (localData.activeTab) {
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
if (btn) btn.click();
}
}
});
@@ -482,6 +493,24 @@ function renderEmpty(container, type) {
wrapper.appendChild(iconDiv);
wrapper.appendChild(titleDiv);
wrapper.appendChild(hintDiv);
// "No peers yet" — turn the hint into a one-click action so the user can
// immediately copy and share the invite link instead of hunting for it.
if (type === 'peers' && elements.inviteLink && elements.inviteLink.value) {
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'primary';
copyBtn.style.cssText = 'display:inline-block; width:auto; margin-top:12px; padding:6px 14px; font-size:11px;';
copyBtn.textContent = getMessage('BTN_COPY_INVITE');
copyBtn.title = getMessage('BTN_COPY_INVITE_TOOLTIP');
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(elements.inviteLink.value)
.then(() => showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000))
.catch(() => showToast(getMessage('TOAST_COPY_FAILED'), 'error'));
});
wrapper.appendChild(copyBtn);
}
container.replaceChildren(wrapper);
}
@@ -1121,6 +1150,8 @@ elements.tabs.forEach(btn => {
isDevTabVisible = btn.dataset.tab === 'tab-dev';
if (isDevTabVisible) refreshLogs();
if (btn.dataset.tab === 'tab-sync') refreshHistory();
chrome.storage.local.set({ activeTab: btn.dataset.tab });
});
});
@@ -1163,8 +1194,13 @@ elements.roomId.addEventListener('input', () => {
});
elements.joinBtn.addEventListener('click', async () => {
if (isProcessingConnection) return;
isProcessingConnection = true;
clearConnectionErrorTimer();
if (elements.joinBtn.disabled) return;
if (elements.joinBtn.disabled) {
isProcessingConnection = false;
return;
}
const roomIdInput = elements.roomId.value.trim();
const isCreating = !roomIdInput;
@@ -1173,10 +1209,23 @@ elements.joinBtn.addEventListener('click', async () => {
if (joinBtnTimeout) clearTimeout(joinBtnTimeout);
joinBtnTimeout = setTimeout(() => {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
joinBtnTimeout = null;
showError(getMessage('ERR_CONN_TIMEOUT'));
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.status === 'connecting') {
joinBtnTimeout = setTimeout(() => {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
joinBtnTimeout = null;
isProcessingConnection = false;
showError(getMessage('ERR_CONN_TIMEOUT'));
}, 15000);
return;
}
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
joinBtnTimeout = null;
isProcessingConnection = false;
if (res && res.status !== 'connected') showError(getMessage('ERR_CONN_TIMEOUT'));
});
}, 15000);
const serverUrl = elements.serverUrl.value.trim();
@@ -1187,6 +1236,8 @@ elements.joinBtn.addEventListener('click', async () => {
showError(getMessage('ERR_INVALID_SERVER_URL'));
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
isProcessingConnection = false;
return;
}
if (useCustom && serverUrl) {
@@ -1197,6 +1248,8 @@ elements.joinBtn.addEventListener('click', async () => {
showError(getMessage('ERR_INVALID_SERVER_URL'));
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
isProcessingConnection = false;
return;
}
}
@@ -1223,6 +1276,8 @@ elements.joinBtn.addEventListener('click', async () => {
});
elements.leaveBtn.addEventListener('click', async () => {
if (isProcessingConnection) return;
isProcessingConnection = true;
clearConnectionErrorTimer();
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
await chrome.storage.local.set({ roomId: '', password: '' });
@@ -1230,6 +1285,7 @@ elements.leaveBtn.addEventListener('click', async () => {
elements.password.value = '';
lastKnownPeers = [];
updateUI(null, null);
isProcessingConnection = false;
});
function generateRoomId() {
@@ -1280,6 +1336,9 @@ elements.retryBtn.addEventListener('click', () => {
});
elements.targetTab.addEventListener('change', () => {
const hint = document.getElementById('targetTabHint');
if (hint) hint.style.display = 'none';
const val = elements.targetTab.value;
const tabId = val ? parseInt(val) : null;
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
@@ -1289,8 +1348,14 @@ elements.targetTab.addEventListener('change', () => {
elements.forceSyncBtn.addEventListener('click', async () => {
if (elements.forceSyncBtn.disabled) return;
const originalText = elements.forceSyncBtn.textContent;
elements.forceSyncBtn.disabled = true;
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
if (chrome.runtime.lastError || !status || !status.targetTabId) return;
if (chrome.runtime.lastError || !status || !status.targetTabId) {
elements.forceSyncBtn.disabled = false;
return;
}
const mode = elements.forceSyncMode.value;
let targetTime = null;
@@ -1298,6 +1363,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
if (mode === 'jump-to-others') {
if (!localPeerId) {
showError(getMessage('ERR_IDENTITY_NOT_LOADED'));
elements.forceSyncBtn.disabled = false;
return;
}
const peers = status.peers || [];
@@ -1307,6 +1373,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
if (otherTimes.length === 0) {
showError(getMessage('ERR_NO_PEERS_TIME'));
elements.forceSyncBtn.disabled = false;
return;
}
@@ -1315,8 +1382,6 @@ elements.forceSyncBtn.addEventListener('click', async () => {
targetTime = otherTimes.length % 2 !== 0 ? otherTimes[mid] : (otherTimes[mid - 1] + otherTimes[mid]) / 2;
}
const originalText = elements.forceSyncBtn.textContent;
elements.forceSyncBtn.disabled = true;
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? getMessage('BTN_STATE_SYNCING_GROUP', { time: formatTime(targetTime) }) : getMessage('BTN_STATE_SYNCING');
forceSyncDone = false;
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
@@ -1572,6 +1637,9 @@ chrome.runtime.onMessage.addListener((msg) => {
updatePeerList(msg.peers);
if (msg.peers) detectPeerChanges(msg.peers);
} else if (msg.type === 'CONNECTION_STATUS') {
if (msg.status === 'connected' || msg.status === 'disconnected') {
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
}
if (msg.status === 'connected') {
clearConnectionErrorTimer();
}
@@ -1610,6 +1678,7 @@ chrome.runtime.onMessage.addListener((msg) => {
} else if (msg.type === 'ROOM_LIST') {
updateRoomList(msg.rooms);
} else if (msg.type === 'JOIN_STATUS') {
isProcessingConnection = false;
if (joinBtnTimeout) {
clearTimeout(joinBtnTimeout);
joinBtnTimeout = null;
@@ -1621,9 +1690,15 @@ chrome.runtime.onMessage.addListener((msg) => {
// Final confirmation of join from background
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
});
} else {
// Join failed: reset UI state
chrome.storage.local.set({ roomId: '', password: '' });
elements.roomId.value = '';
elements.password.value = '';
updateUI(null, null);
}
} else if (msg.type === 'LOBBY_UPDATE') {
@@ -2049,36 +2124,188 @@ function updateLobbyUI(lobby, peers) {
// --- Onboarding Tour ---
const onboardingSteps = [
{ icon: '👋', get title() { return getMessage('ONBOARDING_1_TITLE'); }, get text() { return getMessage('ONBOARDING_1_TEXT'); }, targetTab: 'tab-room' },
{ icon: '🏠', get title() { return getMessage('ONBOARDING_2_TITLE'); }, get text() { return getMessage('ONBOARDING_2_TEXT'); }, targetTab: 'tab-room' },
{ icon: '🎬', get title() { return getMessage('ONBOARDING_3_TITLE'); }, get text() { return getMessage('ONBOARDING_3_TEXT'); }, targetTab: 'tab-sync' },
{ icon: '⚙️', get title() { return getMessage('ONBOARDING_4_TITLE'); }, get text() { return getMessage('ONBOARDING_4_TEXT'); }, targetTab: 'tab-settings' },
{ icon: '🎉', get title() { return getMessage('ONBOARDING_5_TITLE'); }, get text() { return getMessage('ONBOARDING_5_TEXT'); }, targetTab: 'tab-room' }
{
icon: '👋',
get title() { return getMessage('ONBOARDING_1_TITLE'); },
get text() { return getMessage('ONBOARDING_1_TEXT'); },
targetTab: 'tab-room',
targetSelector: null
},
{
icon: '🏠',
get title() { return getMessage('ONBOARDING_2_TITLE'); },
get text() { return getMessage('ONBOARDING_2_TEXT'); },
targetTab: 'tab-room',
targetSelector: '#createRoomBtn'
},
{
icon: '🎬',
get title() { return getMessage('ONBOARDING_3_TITLE'); },
get text() { return getMessage('ONBOARDING_3_TEXT'); },
targetTab: 'tab-sync',
targetSelector: '#targetTab'
},
{
icon: '⚙️',
get title() { return getMessage('ONBOARDING_4_TITLE'); },
get text() { return getMessage('ONBOARDING_4_TEXT'); },
targetTab: 'tab-settings',
targetSelector: '#username'
},
{
icon: '🍿',
get title() { return getMessage('ONBOARDING_5_TITLE'); },
get text() { return getMessage('ONBOARDING_5_TEXT'); },
targetTab: 'tab-room',
targetSelector: null
}
];
function showSelectVideoHint() {
const hint = document.getElementById('targetTabHint');
if (hint && !elements.targetTab.value) {
hint.style.display = 'block';
}
}
let onboardingStep = 0;
let onboardingTimeout = null;
function showOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
if (!overlay) return;
document.body.style.minHeight = '400px';
overlay.style.display = 'flex';
document.body.style.minHeight = '420px';
overlay.style.display = 'block';
onboardingStep = 0;
renderOnboardingStep();
}
function positionSpotlightAndCard(targetEl) {
const spotlight = document.getElementById('onboarding-spotlight');
const card = document.getElementById('onboarding-card');
const arrow = document.getElementById('onboarding-arrow');
if (!spotlight || !card || !arrow) return;
spotlight.style.display = 'block';
card.style.display = 'block';
// Force a reflow
card.getBoundingClientRect();
spotlight.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const pad = 6;
const sLeft = targetRect.left - pad;
const sTop = targetRect.top - pad;
const sWidth = targetRect.width + (pad * 2);
const sHeight = targetRect.height + (pad * 2);
spotlight.style.left = `${sLeft}px`;
spotlight.style.top = `${sTop}px`;
spotlight.style.width = `${sWidth}px`;
spotlight.style.height = `${sHeight}px`;
spotlight.style.opacity = '1';
const cardWidth = 280;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
let cLeft = targetRect.left + (targetRect.width - cardWidth) / 2;
cLeft = Math.max(12, Math.min(320 - cardWidth - 12, cLeft));
const cardHeight = card.offsetHeight || 150;
let cTop = targetRect.bottom + 14;
let arrowDir = 'up';
if (cTop + cardHeight > viewportHeight - 12) {
cTop = targetRect.top - cardHeight - 14;
arrowDir = 'down';
}
if (cTop < 12) {
cTop = targetRect.bottom + 14;
arrowDir = 'up';
}
card.style.left = `${cLeft}px`;
card.style.top = `${cTop}px`;
card.style.opacity = '1';
card.style.transform = 'scale(1)';
arrow.style.left = '';
arrow.style.right = '';
arrow.style.top = '';
arrow.style.bottom = '';
arrow.style.borderColor = 'transparent';
const targetCenter = targetRect.left + (targetRect.width / 2);
const arrowLeft = targetCenter - cLeft - 6;
arrow.style.left = `${Math.max(12, Math.min(cardWidth - 24, arrowLeft))}px`;
if (arrowDir === 'up') {
arrow.style.top = '-12px';
arrow.style.borderBottomColor = 'var(--card)';
} else {
arrow.style.bottom = '-12px';
arrow.style.borderTopColor = 'var(--card)';
}
}
function centerCardFallback() {
const spotlight = document.getElementById('onboarding-spotlight');
const card = document.getElementById('onboarding-card');
const arrow = document.getElementById('onboarding-arrow');
if (!card) return;
if (spotlight) {
spotlight.style.display = 'none';
spotlight.style.opacity = '0';
}
if (arrow) {
arrow.style.borderColor = 'transparent';
}
card.style.display = 'block';
const cardWidth = 280;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const cLeft = (320 - cardWidth) / 2;
const cTop = (viewportHeight - (card.offsetHeight || 150)) / 2;
card.style.left = `${cLeft}px`;
card.style.top = `${cTop}px`;
card.style.opacity = '1';
card.style.transform = 'scale(1)';
}
function renderOnboardingStep() {
if (onboardingTimeout) clearTimeout(onboardingTimeout);
const step = onboardingSteps[onboardingStep];
const icon = document.getElementById('onboarding-icon');
const title = document.getElementById('onboarding-title');
const text = document.getElementById('onboarding-text');
const nextBtn = document.getElementById('onboarding-next');
const dots = document.getElementById('onboarding-dots');
if (!icon || !title || !text || !nextBtn || !dots) return;
const stepIndicator = document.getElementById('onboarding-step-indicator');
const progressBar = document.getElementById('onboarding-progress-bar');
if (!icon || !title || !text || !nextBtn) return;
icon.textContent = step.icon;
title.textContent = step.title;
text.textContent = step.text;
if (stepIndicator) {
stepIndicator.textContent = `Step ${onboardingStep + 1} of ${onboardingSteps.length}`;
}
if (progressBar) {
progressBar.style.width = `${((onboardingStep + 1) / onboardingSteps.length) * 100}%`;
}
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1
? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!')
: getMessage('BTN_ONBOARDING_NEXT');
if (step.targetTab) {
const tabBtn = document.querySelector(`.tab-btn[data-tab="${step.targetTab}"]`);
if (tabBtn) tabBtn.click();
@@ -2089,31 +2316,53 @@ function renderOnboardingStep() {
if (syncActive) syncActive.style.display = 'block';
if (syncInactive) syncInactive.style.display = 'none';
} else {
// Restore actual lock state when on other tabs so we don't leave it unlocked
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none';
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
}
}
dots.replaceChildren();
onboardingSteps.forEach((_, i) => {
const dot = document.createElement('div');
dot.style.cssText = `width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};`;
dots.appendChild(dot);
});
const spotlight = document.getElementById('onboarding-spotlight');
const card = document.getElementById('onboarding-card');
if (spotlight) spotlight.style.opacity = '0';
if (card) {
card.style.opacity = '0';
card.style.transform = 'scale(0.95)';
}
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1 ? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!') : getMessage('BTN_ONBOARDING_NEXT');
onboardingTimeout = setTimeout(() => {
const targetEl = step.targetSelector ? document.querySelector(step.targetSelector) : null;
if (targetEl && targetEl.offsetParent !== null) {
positionSpotlightAndCard(targetEl);
} else {
centerCardFallback();
}
}, 180);
}
function completeOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
if (overlay) overlay.style.display = 'none';
document.body.style.minHeight = '';
chrome.storage.sync.set({ onboardingComplete: true });
if (onboardingTimeout) clearTimeout(onboardingTimeout);
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
toggleUIState(inRoom);
const overlay = document.getElementById('onboarding-overlay');
const spotlight = document.getElementById('onboarding-spotlight');
const card = document.getElementById('onboarding-card');
if (card) {
card.style.opacity = '0';
card.style.transform = 'scale(0.9)';
}
if (spotlight) {
spotlight.style.opacity = '0';
}
setTimeout(() => {
if (overlay) overlay.style.display = 'none';
document.body.style.minHeight = '';
chrome.storage.sync.set({ onboardingComplete: true });
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
toggleUIState(inRoom);
}, 300);
}
document.getElementById('onboarding-next')?.addEventListener('click', () => {
@@ -2130,3 +2379,8 @@ document.getElementById('onboarding-skip')?.addEventListener('click', completeOn
document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'onboarding-overlay') completeOnboarding();
});
elements.restartTourBtn?.addEventListener('click', () => {
onboardingStep = 0;
showOnboarding();
});
+109 -112
View File
@@ -6,10 +6,10 @@
"packages": {
"": {
"name": "koalasync",
"version": "2.2.2",
"version": "2.2.4",
"devDependencies": {
"archiver": "^7.0.1",
"esbuild": "^0.28.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0",
"sharp": "^0.34.5",
@@ -28,9 +28,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -45,9 +45,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -62,9 +62,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -79,9 +79,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -96,9 +96,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -113,9 +113,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -130,9 +130,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -147,9 +147,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -164,9 +164,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -181,9 +181,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -198,9 +198,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -215,9 +215,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -232,9 +232,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -249,9 +249,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -266,9 +266,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -283,9 +283,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -300,9 +300,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -317,9 +317,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -334,9 +334,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -351,9 +351,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -368,9 +368,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -385,9 +385,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -402,9 +402,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -419,9 +419,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -436,9 +436,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -453,9 +453,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -1240,7 +1240,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1374,7 +1373,6 @@
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -1837,9 +1835,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1850,32 +1848,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escape-string-regexp": {
@@ -1897,7 +1895,6 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
+5 -3
View File
@@ -1,17 +1,19 @@
{
"name": "koalasync",
"version": "2.2.4",
"version": "2.4.4",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
"scripts": {
"build:extension": "node scripts/build-extension.js",
"build:extension": "node scripts/build-extension.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node scripts/verify-release.mjs",
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
"archiver": "^7.0.1",
"esbuild": "^0.28.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0",
"sharp": "^0.34.5",
+2 -2
View File
@@ -2,7 +2,7 @@
This directory contains utility scripts for the KoalaSync development workflow.
## build-extension.js
## build-extension.cjs
The primary build tool for KoalaSync. This Node.js script automates two critical tasks:
@@ -15,7 +15,7 @@ The primary build tool for KoalaSync. This Node.js script automates two critical
From the **repository root**, run:
```bash
node scripts/build-extension.js
node scripts/build-extension.cjs
```
### Why this script exists
@@ -38,7 +38,7 @@ const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
// Helper to copy files, ignoring manifest.json and manifest.base.json
// Also injects shared constants into content.js
function copyExtensionFiles(targetDir) {
function copyExtensionFiles(targetDir, browserName) {
fs.mkdirSync(targetDir, { recursive: true });
// Read master constants for injection
@@ -77,7 +77,7 @@ function copyExtensionFiles(targetDir) {
const eStart = '// --- SHARED_EVENTS_INJECT_START ---';
const eEnd = '// --- SHARED_EVENTS_INJECT_END ---';
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.js\n const EVENTS = ${eventsObject};\n ${eEnd}`;
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n ${eEnd}`;
if (ePattern.test(content)) {
content = content.replace(ePattern, eRep);
@@ -97,8 +97,49 @@ function copyExtensionFiles(targetDir) {
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
}
// 3. Inject Episode Utils
const euStart = '// --- SHARED_EPISODE_UTILS_INJECT_START ---';
const euEnd = '// --- SHARED_EPISODE_UTILS_INJECT_END ---';
const euPattern = new RegExp(euStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]+?' + euEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const euPath = path.join(rootDir, 'extension', 'episode-utils.js');
if (fs.existsSync(euPath)) {
const euContent = fs.readFileSync(euPath, 'utf8');
const stripped = euContent
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
.replace(/export function /g, 'function ')
.trim();
const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`;
if (euPattern.test(content)) {
content = content.replace(euPattern, euRep);
} else {
console.warn('⚠ WARNING: Episode utils markers not found in content.js');
}
}
fs.writeFileSync(destPath, content);
console.log('✓ Injected shared constants into content.js');
} else if (item === 'background.js') {
let content = fs.readFileSync(srcPath, 'utf8');
// 3. Inject Uninstall URL Constants
const uStart = '// --- UNINSTALL_URL_INJECT_START ---';
const uEnd = '// --- UNINSTALL_URL_INJECT_END ---';
const uPattern = new RegExp(`${uStart}[\\s\\S]+?${uEnd}`);
const placeholderUrl = "https://bye.koalastuff.net/c/camp_99ztjRVbK1BNN2RU";
let uRep = `${uStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n`;
uRep += ` const UNINSTALL_URL = "${placeholderUrl}";\n`;
uRep += ` const BROWSER_TYPE = "${browserName}";\n`;
uRep += ` ${uEnd}`;
if (uPattern.test(content)) {
content = content.replace(uPattern, uRep);
} else {
console.warn('⚠️ WARNING: Uninstall URL markers not found in background.js');
}
fs.writeFileSync(destPath, content);
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
} else {
fs.copyFileSync(srcPath, destPath);
}
@@ -127,7 +168,7 @@ async function buildBrowser(browserName, manifestModifier) {
const browserDistDir = path.join(distDir, browserName);
// 1. Copy files
copyExtensionFiles(browserDistDir);
copyExtensionFiles(browserDistDir, browserName);
// 2. Modify and write manifest
const browserManifest = manifestModifier(JSON.parse(JSON.stringify(baseManifest)));
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { extractEpisodeId, sameEpisode } from '../extension/episode-utils.js';
// --- extractEpisodeId ---
// Standard SxxExx patterns
assert.equal(extractEpisodeId('S01E01'), 'S01E01');
assert.equal(extractEpisodeId('S1E1'), 'S01E01');
assert.equal(extractEpisodeId('s01e01'), 'S01E01', 'case insensitive');
assert.equal(extractEpisodeId('Season 1 Episode 2'), 'S01E02');
assert.equal(extractEpisodeId('season 01 episode 02'), 'S01E02');
// Separators: dash, dot, slash, colon, space, comma
assert.equal(extractEpisodeId('S01 - E01'), 'S01E01', 'dash separator');
assert.equal(extractEpisodeId('S01.E01'), 'S01E01', 'dot separator');
assert.equal(extractEpisodeId('S01/E01'), 'S01E01', 'slash separator (Crunchyroll)');
assert.equal(extractEpisodeId('S01:E01'), 'S01E01', 'colon separator');
assert.equal(extractEpisodeId('S01,E01'), 'S01E01', 'comma separator');
assert.equal(extractEpisodeId('S01 E01'), 'S01E01', 'space separator');
// German / multi-language
assert.equal(extractEpisodeId('Folge 5'), 'EP005');
assert.equal(extractEpisodeId('Episode 12'), 'EP012');
assert.equal(extractEpisodeId('Ep. 3'), 'EP003');
assert.equal(extractEpisodeId('#42'), 'EP042');
// Edge cases
assert.equal(extractEpisodeId(null), null);
assert.equal(extractEpisodeId(undefined), null);
assert.equal(extractEpisodeId(''), null);
assert.equal(extractEpisodeId(123), null);
assert.equal(extractEpisodeId('Some Movie Title'), null);
assert.equal(extractEpisodeId('Breaking Bad'), null);
// Leading zeros preserved
assert.equal(extractEpisodeId('S01E001'), 'S01E001');
// --- sameEpisode ---
// Identical episodes
assert.equal(sameEpisode('S01E01', 'S01E01'), true);
assert.equal(sameEpisode('S01E01 - Pilot', 'S01E01'), true, 'extra text ignored');
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German vs English');
// Different episodes
assert.equal(sameEpisode('S01E01', 'S01E02'), false);
assert.equal(sameEpisode('Folge 1', 'Folge 2'), false);
assert.equal(sameEpisode('S01E01', 'S02E01'), false);
// Both unknown → assume same (backward compat)
assert.equal(sameEpisode(null, null), true);
assert.equal(sameEpisode(undefined, undefined), true);
assert.equal(sameEpisode('', ''), true);
assert.equal(sameEpisode('Some Movie', 'Some Movie'), true);
assert.equal(sameEpisode('Some Movie', 'Other Movie'), false, 'different unknowns differ');
// One unknown, one known → different
assert.equal(sameEpisode('S01E01', null), false);
assert.equal(sameEpisode(null, 'Episode 5'), false);
assert.equal(sameEpisode(undefined, 'S01E01'), false);
// Mixed formats — only match when the same episode
assert.equal(sameEpisode('S01E05', 'S01E05'), true, 'same SxxExx');
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German Folge vs English Episode');
assert.equal(sameEpisode('Episode 12', 'Ep. 12'), true, 'Episode X vs Ep. X');
assert.equal(sameEpisode('#42', 'Folge 42'), true, '#X vs Folge X');
// Different format IDs → different (season-tagged vs seasonless)
assert.equal(sameEpisode('S01E05', 'Episode 5'), false, 'SxxExx vs Episode X: different IDs');
assert.equal(sameEpisode('S01E01', 'EP001'), false, 'SxxExx vs EPxxx: different IDs');
// parseable but truly different
assert.equal(sameEpisode('S01E01', 'S01E02'), false, 'different episodes');
assert.equal(sameEpisode('S01E01', 'S02E01'), false, 'different seasons');
console.log('episode-utils tests passed');
@@ -48,7 +48,7 @@ try {
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
const enKeys = Object.keys(enDict);
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json') && file !== 'en.json');
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json'));
console.log(`Auditing i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
@@ -79,8 +79,21 @@ for (const file of localeFiles) {
const missingKeys = enKeys.filter(k => !keys.includes(k));
const extraKeys = keys.filter(k => !enKeys.includes(k));
const emptyKeys = keys.filter(k => typeof dict[k] === 'string' && dict[k].trim() === '');
const placeholderKeys = keys.filter(k => {
if (typeof dict[k] !== 'string') return false;
const val = dict[k];
const lower = val.toLowerCase();
return (
lower.includes('placeholder') ||
lower.includes('todo:') ||
lower.includes('tbd:') ||
/\bTODO\b/.test(val) ||
/\bTBD\b/.test(val)
);
});
if (missingKeys.length > 0 || extraKeys.length > 0) {
if (missingKeys.length > 0 || extraKeys.length > 0 || emptyKeys.length > 0 || placeholderKeys.length > 0) {
hasError = true;
console.error(`${file} has inconsistencies:`);
if (missingKeys.length > 0) {
@@ -89,6 +102,12 @@ for (const file of localeFiles) {
if (extraKeys.length > 0) {
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
}
if (emptyKeys.length > 0) {
console.error(` Empty translation values (${emptyKeys.length}):`, emptyKeys);
}
if (placeholderKeys.length > 0) {
console.error(` Placeholder/TODO values (${placeholderKeys.length}):`, placeholderKeys);
}
} else {
console.log(`${file} is fully consistent (matches all keys).`);
}
@@ -109,7 +128,8 @@ const chromeLocalesDir = path.join(__dirname, '..', 'extension', '_locales');
const chromeLocaleMap = {
'en': 'en', 'de': 'de', 'fr': 'fr', 'es': 'es', 'it': 'it',
'ja': 'ja', 'ko': 'ko', 'nl': 'nl', 'pl': 'pl',
'pt-BR': 'pt_BR', 'pt': 'pt_PT', 'ru': 'ru', 'tr': 'tr'
'pt-BR': 'pt_BR', 'pt': 'pt_PT', 'ru': 'ru', 'tr': 'tr',
'zh': 'zh_CN', 'uk': 'uk'
};
// Read SUPPORTED_LANGUAGES from i18n.js
+56
View File
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from '../shared/names.js';
// --- getAvatarForName (deterministic) ---
// Exact matches
assert.equal(getAvatarForName('Koala'), '🐨', 'Koala');
assert.equal(getAvatarForName('Tiger'), '🐯', 'Tiger');
assert.equal(getAvatarForName('Panda'), '🐼', 'Panda');
assert.equal(getAvatarForName('Fox'), '🦊', 'Fox');
// Case insensitive
assert.equal(getAvatarForName('koala'), '🐨', 'lowercase');
assert.equal(getAvatarForName('MyKoalaUser'), '🐨', 'embedded uppercase');
// Longest match wins (caterpillar > cat)
assert.equal(getAvatarForName('CaterpillarCat'), '🐛', 'caterpillar before cat');
assert.equal(getAvatarForName('Cat'), '🐱', 'cat alone');
// Emoji with ZWJ sequences (multi-codepoint)
assert.equal(getAvatarForName('Polar'), '🐻\u200D❄️', 'polar bear ZWJ');
assert.equal(getAvatarForName('Crow'), '🐦\u200D⬛', 'crow ZWJ');
// Human-like characters
assert.equal(getAvatarForName('Ninja'), '🥷', 'ninja');
assert.equal(getAvatarForName('Wizard'), '🧙', 'wizard');
assert.equal(getAvatarForName('Pirate'), '🏴', 'pirate');
assert.equal(getAvatarForName('Alien'), '👾', 'alien');
assert.equal(getAvatarForName('Robot'), '🤖', 'robot');
// Fallback
assert.equal(getAvatarForName(''), '👤', 'empty string');
assert.equal(getAvatarForName('Xyzzy123'), '👤', 'unknown name');
assert.equal(getAvatarForName(null), '👤', 'null');
assert.equal(getAvatarForName(undefined), '👤', 'undefined');
// --- generateUsername (format check) ---
for (let i = 0; i < 10; i++) {
const name = generateUsername();
// Format: AdjectiveNoun (e.g. "HappyKoala")
assert.ok(/^[A-Z][a-z]+[A-Z][a-z]+$/.test(name), `format: ${name}`);
// Adjective from list
const adj = USERNAME_ADJECTIVES.some(a => name.startsWith(a));
assert.ok(adj, `adjective from list: ${name}`);
// Noun from list
const noun = USERNAME_NOUNS.some(n => name.endsWith(n));
assert.ok(noun, `noun from list: ${name}`);
}
// Every noun has an emoji (no broken usernames)
for (const noun of USERNAME_NOUNS) {
const avatar = getAvatarForName(noun);
assert.notEqual(avatar, '👤', `noun "${noun}" has no emoji — add to ANIMAL_EMOJI_MAP`);
}
console.log('names tests passed');
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import {
checkConnectionRate,
checkEventRate,
checkHealthRate,
checkAdminMetricsAuthRate,
checkAuthRate,
recordAuthFailure,
clearRateLimitMaps,
connectionCounts,
failedAuthAttempts,
eventCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
rateLimitDenied,
startRateLimitCleanup,
stopRateLimitCleanup,
CONNECTION_RATE_LIMIT,
EVENT_RATE_LIMIT
} from '../server/rate-limiter.js';
// Helper: mock io for cleanup
const mockIo = { sockets: { sockets: new Map() } };
// Reset state before each test group
function reset() {
clearRateLimitMaps();
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
stopRateLimitCleanup();
}
// --- checkConnectionRate ---
reset();
assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed');
// Exhaust the rest of the budget (first call above counted as 1).
for (let i = 0; i < CONNECTION_RATE_LIMIT - 1; i++) checkConnectionRate('1.1.1.1');
assert.equal(checkConnectionRate('1.1.1.1'), false, `connection beyond ${CONNECTION_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented');
reset();
assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent');
// --- checkEventRate ---
reset();
assert.equal(checkEventRate('sock1'), true, 'first event allowed');
// Exhaust the rest of the budget (first call above counted as 1).
for (let i = 0; i < EVENT_RATE_LIMIT - 1; i++) checkEventRate('sock1');
assert.equal(checkEventRate('sock1'), false, `event beyond ${EVENT_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.events, 1);
reset();
assert.equal(checkEventRate('sock2'), true, 'separate socket independent');
// --- checkHealthRate ---
reset();
assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed');
for (let i = 0; i < 9; i++) checkHealthRate('1.2.3.4');
assert.equal(checkHealthRate('1.2.3.4'), false, '11th health check blocked');
assert.equal(rateLimitDenied.health, 1);
// --- checkAdminMetricsAuthRate ---
reset();
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), true, 'first admin auth allowed');
for (let i = 0; i < 4; i++) checkAdminMetricsAuthRate('5.6.7.8');
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), false, '6th admin auth blocked');
assert.equal(rateLimitDenied.adminMetricsAuth, 1);
// --- checkAuthRate ---
reset();
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), true, 'first auth attempt allowed');
for (let i = 0; i < 5; i++) recordAuthFailure('10.0.0.1', 'room-a');
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), false, '6th auth attempt blocked');
assert.equal(checkAuthRate('10.0.0.1', 'room-b'), true, 'different room not blocked');
// --- recordAuthFailure ---
reset();
recordAuthFailure('10.0.0.2', 'room-x');
assert.equal(failedAuthAttempts.size, 1, 'failure recorded');
const record = failedAuthAttempts.get('10.0.0.2:room-x');
assert.equal(record.count, 1, 'count incremented');
assert.ok(record.lastAttempt <= Date.now(), 'timestamp set');
recordAuthFailure('10.0.0.2', 'room-x');
assert.equal(failedAuthAttempts.get('10.0.0.2:room-x').count, 2, 'count increments on repeat');
// --- clearRateLimitMaps ---
reset();
connectionCounts.set('ip1', { count: 1, resetTime: Date.now() + 60000 });
eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 });
healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 });
adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 });
roomListCooldowns.set('sock2', Date.now());
clearRateLimitMaps();
assert.equal(connectionCounts.size, 0, 'connectionCounts cleared');
assert.equal(eventCounts.size, 0, 'eventCounts cleared');
assert.equal(healthCounts.size, 0, 'healthCounts cleared');
assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared');
assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared');
// --- startRateLimitCleanup / stopRateLimitCleanup ---
reset();
startRateLimitCleanup(mockIo);
startRateLimitCleanup(mockIo); // double-start guard
stopRateLimitCleanup();
assert.ok(true, 'cleanup start/stop does not throw');
// --- rateLimitDenied reset ---
reset();
rateLimitDenied.connections = 5;
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable');
console.log('rate-limiter tests passed');
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
const WebSocket = require('ws');
let port, mod, clients = [];
function wsu() { return `ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=2.4.0&token=62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50`; }
async function c() {
const ws = new WebSocket(wsu()); clients.push(ws); ws._m = []; ws.on('message', d => ws._m.push(d.toString()));
await new Promise((r, j) => { const t = setTimeout(() => j(Error('connect')), 5e3); ws.on('open', () => { clearTimeout(t); r(); }); });
ws.send('40'); const s = Date.now(); while (ws._m.length < 2 && Date.now()-s < 5e3) await new Promise(r => setTimeout(r, 50));
if (ws._m.length < 2) throw Error('handshake');
ws._m.length = 0; return ws;
}
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
try {
process.env.ADMIN_METRICS_TOKEN = 'ws-integration-test-32chars-minimum!';
mod = await import('../server/index.js');
await mod.startServer(0,'127.0.0.1');
port = mod.httpServer.address().port;
// --- Pool: 2 peers in 1 room, test everything ---
const rid = 't-'+Date.now();
const p1 = await c(), p2 = await c();
// Room + join
await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0;
// Relay
s(p1,'play',{currentTime:10}); await w(p2,'play');
s(p1,'pause',{currentTime:20}); await w(p2,'pause');
s(p1,'seek',{currentTime:30}); await w(p2,'seek');
// Force Sync
s(p1,'force_sync_prepare',{targetTime:0}); await w(p2,'force_sync_prepare');
s(p1,'force_sync_ack',{}); await w(p2,'force_sync_ack');
s(p1,'force_sync_execute',{}); await w(p2,'force_sync_execute');
// EVENT_ACK
s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack');
// Lobby
s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby');
// Leave
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
close();
// --- Password room ---
const prid = 'pw-'+Date.now();
const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret');
const pw2 = await c();
s(pw2,'join_room',{roomId:prid,password:'BAD',peerId:'bad',protocolVersion:'1.0.0'});
assert.equal((await a(pw2))[0],'error','wrong pw');
const pw3 = await c();
s(pw3,'join_room',{roomId:prid,password:'s3cret',peerId:'good',protocolVersion:'1.0.0'});
assert.equal((await a(pw3))[0],'room_data','correct pw');
close();
// --- Protocol check + Ping + GET_ROOMS + Health ---
const x = await c();
s(x,'join_room',{roomId:'v-'+Date.now(),peerId:'old',protocolVersion:'0.0.1'});
await w(x,'error'); // version mismatch
x._m.length = 0;
s(x,'ping',{t:Date.now()}); await w(x,'pong');
await j(x,'lst-'+Date.now(),'l1');
x._m.length = 0;
s(x,'get_rooms',{}); await w(x,'room_list');
close();
// Dedup
const did = 'dup-'+Date.now();
const d1 = await c(), d2 = await c();
await j(d1, did, 'dup'); d1._m.length = 0;
s(d2,'join_room',{roomId:did,peerId:'dup',protocolVersion:'1.0.0'});
assert.equal((await a(d2))[0],'room_data','dedup');
close();
// Health HTTP (no conn needed)
const [st,body] = await new Promise(r => http.get(`http://127.0.0.1:${port}/`, res => {
let d=''; res.on('data',c=>d+=c); res.on('end',()=>r([res.statusCode,JSON.parse(d)])); }));
assert.equal(st,200); assert.equal(body.status,'online');
console.log('All 13 WebSocket integration tests passed');
} catch(e) {
console.error('FAILED:', e.message);
process.exitCode=1;
} finally {
close();
if (mod?.stopServerForTests) await mod.stopServerForTests();
}
+24 -2
View File
@@ -17,7 +17,7 @@ const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
const enKeys = Object.keys(enDict).sort();
const localeFiles = fs.readdirSync(localesDir)
.filter(file => file.endsWith('.json') && file !== 'en.json')
.filter(file => file.endsWith('.json'))
.sort();
let hasError = false;
@@ -51,8 +51,24 @@ for (const file of localeFiles) {
const missingKeys = enKeys.filter(k => !keys.includes(k));
const extraKeys = keys.filter(k => !enKeys.includes(k));
const emptyKeys = keys.filter(k => {
if (file === 'en.json' && k === 'CANONICAL_PATH') return false;
return typeof dict[k] === 'string' && dict[k].trim() === '';
});
const placeholderKeys = keys.filter(k => {
if (typeof dict[k] !== 'string') return false;
const val = dict[k];
const lower = val.toLowerCase();
return (
lower.includes('placeholder') ||
lower.includes('todo:') ||
lower.includes('tbd:') ||
/\bTODO\b/.test(val) ||
/\bTBD\b/.test(val)
);
});
if (missingKeys.length > 0 || extraKeys.length > 0) {
if (missingKeys.length > 0 || extraKeys.length > 0 || emptyKeys.length > 0 || placeholderKeys.length > 0) {
hasError = true;
console.error(`${file} has inconsistencies:`);
if (missingKeys.length > 0) {
@@ -61,6 +77,12 @@ for (const file of localeFiles) {
if (extraKeys.length > 0) {
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
}
if (emptyKeys.length > 0) {
console.error(` Empty translation values (${emptyKeys.length}):`, emptyKeys);
}
if (placeholderKeys.length > 0) {
console.error(` Placeholder/TODO values (${placeholderKeys.length}):`, placeholderKeys);
}
} else {
console.log(`${file} is fully consistent (matches all keys).`);
}
+8 -3
View File
@@ -11,21 +11,26 @@ const checks = [
['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']],
['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']],
['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']],
['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']],
['names generator', 'node', ['scripts/test-names.mjs']],
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['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']],
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.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']],
['locale coverage', 'node', ['scripts/test-locales.cjs']],
['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']]
['website build', 'node', ['website/build.cjs']]
];
function runCheck([label, command, args, options = {}]) {
+8
View File
@@ -71,3 +71,11 @@ npm start
- **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.
## Module Structure
| File | Purpose |
|---|---|
| `index.js` | Express + Socket.IO server: room management, relay loop, graceful shutdown |
| `rate-limiter.js` | Connection, event, health, and auth rate limiting with 6 functions + cleanup intervals |
| `ops.js` | Health endpoint helpers, metrics payload builder, auth validation |
+97 -179
View File
@@ -12,6 +12,38 @@ import {
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from './ops.js';
import {
ROOM_LIST_COOLDOWN_MS,
HEALTH_RATE_LIMIT_PER_MINUTE,
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
connectionCounts,
failedAuthAttempts,
eventCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
rateLimitDenied,
checkAuthRate,
recordAuthFailure,
checkConnectionRate,
checkEventRate,
checkHealthRate,
checkAdminMetricsAuthRate,
startRateLimitCleanup,
stopRateLimitCleanup,
clearRateLimitMaps
} from './rate-limiter.js';
// Re-export for external consumers (tests, ops)
export {
HEALTH_RATE_LIMIT_PER_MINUTE,
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
healthCounts,
adminMetricsAuthCounts,
connectionCounts,
eventCounts,
rateLimitDenied
};
dotenv.config();
@@ -26,9 +58,6 @@ const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
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;
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
@@ -108,6 +137,8 @@ export const io = new Server(httpServer, {
allowUpgrades: false
});
startRateLimitCleanup(io);
/**
* In-memory storage
*/
@@ -115,167 +146,17 @@ export const rooms = new Map();
const socketToRoom = new Map();
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
const peerJoinLocks = new Map(); // peerId -> Promise (prevents race on same peerId joins)
function log(type, message, details = '') {
const debugLogging = process.env.DEBUG_LOGGING === '1';
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS';
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS' || type === 'ACKDROP';
if (!debugLogging && isVerbose) return;
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${type}] ${message}`, details);
}
// Rate Limiting & Security
export const connectionCounts = new Map(); // ip -> { count, resetTime }
const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
function checkAuthRate(ip, roomId) {
const key = `${ip}:${roomId}`;
const now = Date.now();
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
// Block for 15 mins if 5 fails in 2 mins
if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) {
return false;
}
// Reset if last attempt was long ago
if ((now - record.lastAttempt) > 2 * 60 * 1000) {
record.count = 0;
}
return true;
}
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 15 minutes)
const authFailureCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, record] of failedAuthAttempts.entries()) {
if (now - record.lastAttempt > 15 * 60 * 1000) {
failedAuthAttempts.delete(key);
}
}
}, 15 * 60 * 1000);
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
// Actual rate-limit denial counters (incremented only when a request is denied)
const rateLimitDenied = {
connections: 0,
events: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0
};
// Clean up connection counts and event counts to prevent memory leak
const rateLimitCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [ip, entry] of connectionCounts.entries()) {
if (now > entry.resetTime) {
connectionCounts.delete(ip);
}
}
for (const [socketId, entry] of eventCounts.entries()) {
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) {
const now = Date.now();
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
connectionCounts.set(ip, entry);
if (entry.count <= 10) return true;
rateLimitDenied.connections++;
return false;
}
function checkEventRate(socketId) {
const now = Date.now();
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
entry.count++;
eventCounts.set(socketId, entry);
if (entry.count <= 30) return true;
rateLimitDenied.events++;
return false;
}
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);
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.health++;
return false;
}
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);
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.adminMetricsAuth++;
return false;
}
/**
* Central peer teardown. Removes a socket from all room state and notifies
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
@@ -469,7 +350,20 @@ io.on('connection', (socket) => {
return;
}
if (!createdByMe) {
let peerLockPromise = peerJoinLocks.get(peerId);
if (peerLockPromise) {
await peerLockPromise;
room = rooms.get(roomId);
if (!room) {
socket.emit(EVENTS.ERROR, { message: "Room no longer exists" });
return;
}
}
let resolvePeerLock;
peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; });
peerJoinLocks.set(peerId, peerLockPromise);
try {
if (!createdByMe) {
if (room.passwordHash) {
if (!password || hashPassword(password) !== room.passwordHash) {
recordAuthFailure(ip, roomId);
@@ -527,6 +421,10 @@ io.on('connection', (socket) => {
activeLobby: room.activeLobby || null
});
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
} finally {
peerJoinLocks.delete(peerId);
resolvePeerLock();
}
} catch (err) {
log('ERROR', `Join error for ${socket.id}`, err);
if (socket.connected) {
@@ -644,10 +542,14 @@ io.on('connection', (socket) => {
});
socket.on(EVENTS.LEAVE_ROOM, () => {
const mapping = socketToRoom.get(socket.id);
if (mapping) {
socket.leave(mapping.roomId);
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
try {
const mapping = socketToRoom.get(socket.id);
if (mapping) {
socket.leave(mapping.roomId);
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
}
} catch (err) {
log('ERROR', 'removePeerFromRoom failed in leave', err);
}
});
@@ -667,12 +569,18 @@ io.on('connection', (socket) => {
// 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, {
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
senderId: senderMapping.peerId,
actionTimestamp: data.actionTimestamp
});
} else {
} else if (senderMapping && targetMapping) {
// Both peers exist but live in different rooms — genuinely suspicious.
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
} else {
// Benign + common: sender or target left/disconnected before the ACK
// arrived (a command was in-flight when they went). Not an attack —
// log quietly (verbose only) so it doesn't drown out real signals.
log('ACKDROP', `Dropped ACK from ${socket.id} to absent peer ${data.targetId}`);
}
});
@@ -723,10 +631,11 @@ io.on('connection', (socket) => {
roomListCooldowns.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
if (mapping) {
// Socket is already disconnected — no need to call socket.leave().
// removePeerFromRoom uses io.to() for notifications, which correctly
// excludes this dead socket since it has already left all rooms.
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
try {
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
} catch (err) {
log('ERROR', 'removePeerFromRoom failed in disconnect', err);
}
}
});
});
@@ -751,12 +660,14 @@ const roomCleanupInterval = setInterval(() => {
}
}
for (const sid of staleSids) {
// Gracefully evict the socket from the Socket.IO room if it is
// still technically connected (zombie with no heartbeat).
const deadSocket = io.sockets?.sockets?.get(sid);
if (deadSocket) deadSocket.leave(roomId);
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
removePeerFromRoom(sid, roomId, 'reaper');
try {
removePeerFromRoom(sid, roomId, 'reaper');
} catch (err) {
log('ERROR', 'removePeerFromRoom failed in reaper', err);
}
}
// 2. Prune empty or inactive rooms
@@ -811,22 +722,18 @@ function gracefulShutdown(signal) {
}
export async function stopServerForTests() {
clearInterval(authFailureCleanupInterval);
clearInterval(rateLimitCleanupInterval);
stopRateLimitCleanup();
clearInterval(roomCleanupInterval);
rooms.clear();
socketToRoom.clear();
peerToSocket.clear();
roomCreationLocks.clear();
connectionCounts.clear();
failedAuthAttempts.clear();
eventCounts.clear();
healthCounts.clear();
adminMetricsAuthCounts.clear();
roomListCooldowns.clear();
peerJoinLocks.clear();
clearRateLimitMaps();
healthResponseCache.clear();
io.removeAllListeners();
io.disconnectSockets(true);
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
if (!httpServer.listening) return;
await new Promise((resolve, reject) => {
httpServer.close((err) => err ? reject(err) : resolve());
@@ -846,8 +753,19 @@ if (isMainModule) {
process.exit(1);
});
let unhandledRejectionCount = 0;
let unhandledRejectionReset = Date.now();
process.on('unhandledRejection', (reason) => {
log('ERROR', `Unhandled rejection: ${reason}`);
process.exit(1);
log('ERROR', `Unhandled rejection: ${reason}`, reason?.stack || '');
const now = Date.now();
if (now - unhandledRejectionReset > 60000) {
unhandledRejectionCount = 0;
unhandledRejectionReset = now;
}
unhandledRejectionCount++;
if (unhandledRejectionCount >= 5) {
log('ERROR', `Too many unhandled rejections (${unhandledRejectionCount}/min) — aborting`);
process.exit(1);
}
});
}
+23 -9
View File
@@ -33,8 +33,17 @@ export function isAdminMetricsAuthorized(authHeader, adminToken) {
const expectedBuffer = Buffer.from(adminToken);
const providedBuffer = Buffer.from(provided);
if (expectedBuffer.length !== providedBuffer.length) return false;
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
// Always run timingSafeEqual to prevent length-based timing leaks.
// timingSafeEqual throws on different-length buffers, so when lengths
// differ we compare against a zeroed buffer of the provided length
// (guaranteed mismatch, constant time).
// NOTE: timingSafeEqual must be evaluated eagerly (assigned to const)
// before the && short-circuit, otherwise it's skipped on length mismatch
// and the timing leak remains.
const sameLength = expectedBuffer.length === providedBuffer.length;
const compareBuf = sameLength ? expectedBuffer : Buffer.alloc(providedBuffer.length);
const equal = crypto.timingSafeEqual(compareBuf, providedBuffer);
return sameLength && equal;
}
export function isAdminMetricsTokenStrong(adminToken, minLength = 32) {
@@ -61,19 +70,24 @@ export function buildHealthPayload({
if (!includeMetrics) return payload;
const roomValues = Array.from(rooms.values());
const roomSizes = roomValues.map(room => room.peers?.size || 0);
const peers = roomSizes.reduce((sum, size) => sum + size, 0);
const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0;
const avgPeersPerRoom = roomSizes.length > 0
? Math.round((peers / roomSizes.length) * 100) / 100
let peers = 0;
let maxPeersInRoom = 0;
let roomsWithLobby = 0;
for (const room of rooms.values()) {
const size = room.peers?.size || 0;
peers += size;
if (size > maxPeersInRoom) maxPeersInRoom = size;
if (room.activeLobby) roomsWithLobby++;
}
const avgPeersPerRoom = rooms.size > 0
? Math.round((peers / rooms.size) * 100) / 100
: 0;
const mem = memoryUsage();
return {
...payload,
peers,
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
roomsWithLobby,
avgPeersPerRoom,
maxPeersInRoom,
rateLimits: {
+11 -11
View File
@@ -256,9 +256,9 @@
}
},
"node_modules/engine.io": {
"version": "6.6.8",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
@@ -270,7 +270,7 @@
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.20.1"
"ws": "~8.21.0"
},
"engines": {
"node": ">=10.2.0"
@@ -941,13 +941,13 @@
}
},
"node_modules/socket.io-adapter": {
"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==",
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.20.1"
"ws": "~8.21.0"
}
},
"node_modules/socket.io-parser": {
@@ -1069,9 +1069,9 @@
"license": "ISC"
},
"node_modules/ws": {
"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==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+172
View File
@@ -0,0 +1,172 @@
/**
* KoalaSync Rate Limiter
* Connection, event, health, and auth rate limiting for the relay server.
*/
export const ROOM_LIST_COOLDOWN_MS = 10000;
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
// --- Connection & event budgets (formerly inline magic numbers) ---
export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP per window
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
export const connectionCounts = new Map(); // ip -> { count, resetTime }
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
export const eventCounts = new Map(); // socketId -> { count, resetTime }
export const healthCounts = new Map(); // ip -> { count, resetTime }
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
export const rateLimitDenied = {
connections: 0,
events: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0
};
let authCleanupId = null;
let rateLimitCleanupId = null;
export function checkAuthRate(ip, roomId) {
const key = `${ip}:${roomId}`;
const now = Date.now();
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) {
return false;
}
if ((now - record.lastAttempt) > 2 * 60 * 1000) {
record.count = 0;
}
return true;
}
export function recordAuthFailure(ip, roomId) {
if (failedAuthAttempts.size > 200000) {
const now = Date.now();
for (const [key, record] of failedAuthAttempts.entries()) {
if (now - record.lastAttempt > 15 * 60 * 1000) {
failedAuthAttempts.delete(key);
} else {
break;
}
}
if (failedAuthAttempts.size > 200000) {
console.warn('SECURITY: failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.');
for (const [key] of failedAuthAttempts.entries()) {
if (failedAuthAttempts.size <= 190000) {
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);
failedAuthAttempts.set(key, record);
}
export function checkConnectionRate(ip) {
const now = Date.now();
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + CONNECTION_RATE_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CONNECTION_RATE_WINDOW_MS; }
entry.count++;
connectionCounts.set(ip, entry);
if (entry.count <= CONNECTION_RATE_LIMIT) return true;
rateLimitDenied.connections++;
return false;
}
export function checkEventRate(socketId) {
const now = Date.now();
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + EVENT_RATE_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + EVENT_RATE_WINDOW_MS; }
entry.count++;
eventCounts.set(socketId, entry);
if (entry.count <= EVENT_RATE_LIMIT) return true;
rateLimitDenied.events++;
return false;
}
export function checkHealthRate(ip) {
const now = Date.now();
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + HEALTH_RATE_WINDOW_MS; }
entry.count++;
healthCounts.set(ip, entry);
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.health++;
return false;
}
export function checkAdminMetricsAuthRate(ip) {
const now = Date.now();
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + ADMIN_METRICS_AUTH_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + ADMIN_METRICS_AUTH_WINDOW_MS; }
entry.count++;
adminMetricsAuthCounts.set(ip, entry);
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
rateLimitDenied.adminMetricsAuth++;
return false;
}
export function startRateLimitCleanup(io) {
if (authCleanupId !== null || rateLimitCleanupId !== null) return; // guard double-start
// Clean up old auth failure records (every 15 minutes)
authCleanupId = setInterval(() => {
const now = Date.now();
for (const [key, record] of failedAuthAttempts.entries()) {
if (now - record.lastAttempt > 15 * 60 * 1000) {
failedAuthAttempts.delete(key);
}
}
}, 15 * 60 * 1000);
// Clean up rate-limit maps to prevent memory leaks (every 60 seconds)
rateLimitCleanupId = setInterval(() => {
const now = Date.now();
for (const [ip, entry] of connectionCounts.entries()) {
if (now > entry.resetTime) connectionCounts.delete(ip);
}
for (const [socketId, entry] of eventCounts.entries()) {
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);
}
export function stopRateLimitCleanup() {
if (authCleanupId) { clearInterval(authCleanupId); authCleanupId = null; }
if (rateLimitCleanupId) { clearInterval(rateLimitCleanupId); rateLimitCleanupId = null; }
}
export function clearRateLimitMaps() {
connectionCounts.clear();
failedAuthAttempts.clear();
eventCounts.clear();
healthCounts.clear();
adminMetricsAuthCounts.clear();
roomListCooldowns.clear();
}
+1 -1
View File
@@ -4,7 +4,7 @@ This directory contains constants and protocol definitions used by both the exte
## Syncing with the Extension
> [!IMPORTANT]
> Every time this directory is modified, you must run `node scripts/build-extension.js` to keep the extension's copy up to date.
> Every time this directory is modified, you must run `node scripts/build-extension.cjs` to keep the extension's copy up to date.
Because Browser Extensions (Manifest V3) cannot load files outside their root directory, all files in this directory must be copied to `extension/shared/` whenever they are modified. The build script handles this automatically.
+1 -1
View File
@@ -2,7 +2,7 @@
* blacklist.js
*
* WARNING: This is the SINGLE SOURCE OF TRUTH.
* If you edit this file, you MUST run: node scripts/build-extension.js
* If you edit this file, you MUST run: node scripts/build-extension.cjs
* to propagate changes to the extension and relay server.
*
* Domains to be filtered out from the tab selection dropdown to reduce "noise".
+2 -3
View File
@@ -2,18 +2,17 @@
* KoalaSync Shared Constants & Protocol Definitions
*
* WARNING: This is the SINGLE SOURCE OF TRUTH.
* If you edit this file, you MUST run: node scripts/build-extension.js
* If you edit this file, you MUST run: node scripts/build-extension.cjs
* to propagate changes to the extension and relay server.
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.9.0";
export const APP_VERSION = "2.4.4";
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() {
+1 -1
View File
@@ -2,7 +2,7 @@
* KoalaSync Shared Name Generation & Emoji Mapping
*
* WARNING: This is the SINGLE SOURCE OF TRUTH.
* If you edit this file, you MUST run: node scripts/build-extension.js
* If you edit this file, you MUST run: node scripts/build-extension.cjs
* to propagate changes to the extension.
*
* The emoji map covers every animal/creature that has a Unicode emoji.
+5 -5
View File
@@ -5,7 +5,7 @@ This directory contains the KoalaSync website. It serves a dual purpose: it is b
## Core Roles
### 1. Marketing & Onboarding
Provides a premium, multi-language (EN/DE/FR/ES/PT-BR/RU) overview of features, setup instructions, and direct links to the extension stores.
Provides a premium, multi-language (EN/DE/FR/ES/PT-BR/RU/IT/PL/TR/NL/JA/KO/PT) overview of features, setup instructions, and direct links to the extension stores.
### 2. The Invitation Bridge (`join.html`)
The website handles incoming invitation links. When a user clicks a link like `sync.koalastuff.net/join.html#join:roomID:pass`, the website:
@@ -16,8 +16,8 @@ The website handles incoming invitation links. When a user clicks a link like `s
## Architecture
The website is 100% **Static HTML, CSS, and JS**.
- **Static i18n Compiler**: The site uses a lightweight, zero-dependency Node.js compiler (`build.js`) to parse dictionary files inside `/locales/` against a single source-of-truth template (`template.html`), outputting the fully deployable static folder to `/www/`.
- **Build-time Minification**: `build.js` automatically minifies CSS and JS during compilation using a built-in state-machine tokenizer (no npm dependencies). Source files are written unminified (`style.css`, `app.js`, `lang-init.js`) — always edit source files, never the generated `.min.*` files in `www/`.
- **Static i18n Compiler**: The site uses a lightweight, zero-dependency Node.js compiler (`build.cjs`) to parse dictionary files inside `/locales/` against a single source-of-truth template (`template.html`), outputting the fully deployable static folder to `/www/`.
- **Build-time Minification**: `build.cjs` automatically minifies CSS and JS during compilation using a built-in state-machine tokenizer (no npm dependencies). Source files are written unminified (`style.css`, `app.js`, `lang-init.js`) — always edit source files, never the generated `.min.*` files in `www/`.
- **Zero Backend**: No Node.js, PHP, or databases are required to host the compiled website.
- **Zero Tracking**: All assets (fonts, icons) are self-hosted to prevent third-party tracking.
- **Responsive**: Fully optimized for mobile with a native-feel hamburger menu.
@@ -62,7 +62,7 @@ sync.koalastuff.net {
1. Run the compilation script from the repository root to generate the `/website/www` folder:
```bash
node website/build.js
node website/build.cjs
```
2. Serve the compiled `/www` directory using any local development server:
```bash
@@ -71,4 +71,4 @@ sync.koalastuff.net {
3. To test the invitation flow locally, navigate to `http://localhost:5000/join.html#join:test-room:test-pass`.
> [!IMPORTANT]
> **Never edit files inside `website/www/` directly.** This directory is fully auto-generated by `build.js`. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, locale files in `locales/`) and re-run `node website/build.js` to apply changes. CSS and JS are output as `style.min.css`, `app.min.js`, and `lang-init.min.js` — the `.min.*` naming makes it visually obvious these are build artifacts. Editing minified files in `www/` will result in lost changes on the next build.
> **Never edit files inside `website/www/` directly.** This directory is fully auto-generated by `build.cjs`. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, locale files in `locales/`) and re-run `node website/build.cjs` to apply changes. CSS and JS are output as `style.min.css`, `app.min.js`, and `lang-init.min.js` — the `.min.*` naming makes it visually obvious these are build artifacts. Editing minified files in `www/` will result in lost changes on the next build.
-194
View File
@@ -1,194 +0,0 @@
# KoalaSync Translation & Localization Guide
Welcome to the **KoalaSync** translation and internationalization framework! This document provides clear, professional instructions for developers and contributors looking to maintain, audit, or add new languages to the official KoalaSync website.
---
## 🏛️ Architecture Overview
The KoalaSync website utilizes a custom, high-performance, zero-dependency static site generator built in Node.js. Instead of using complex client-side translation runtimes or bulky frameworks, localized pages are compiled ahead-of-time (AOT) to maintain lightning-fast page speeds and strict data sovereignty.
* **Template Source:** [`website/template.html`](file:///Users/koala/Documents/KoalaPlay/website/template.html) (Single Source of Truth)
* **Locales Source:** `/website/locales/[lang].json` (Structured JSON translation dictionaries)
* **Build Pipeline:** [`website/build.js`](file:///Users/koala/Documents/KoalaPlay/website/build.js) (Pure Node.js script that compiles pages into `/website/www/`)
---
## 📊 Supported Languages Dashboard
We divide supported languages into two tiers: **Core Languages** (fully hand-crafted and audited by native speakers) and **Extended Languages** (auto-generated using translation models to expand initial coverage).
> [!TIP]
> **Help Us Improve!**
> We welcome community contributions to audit "Auto-Generated" translations and elevate them to "Verified" status.
| Language Code | Language Name | Verification Status | Rationale / Context |
| :--- | :--- | :--- | :--- |
| `en` | 🇬🇧 **English** | `100% Manually Verified` | Primary developer language and system default |
| `de` | 🇩🇪 **German** | `100% Manually Verified` | Core market and compliance baseline |
| `fr` | 🇫🇷 **French** | `Auto-Generated` | Needs manual native review and polishing |
| `es` | 🇪🇸 **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
| `pt-BR` | 🇧🇷 **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
| `ru` | 🇷🇺 **Russian** | `Auto-Generated` | Needs manual native review and polishing |
| `it` | 🇮🇹 **Italian** | `Auto-Generated` | Needs manual native review and polishing |
| `pl` | 🇵🇱 **Polish** | `Auto-Generated` | Needs manual native review and polishing |
| `tr` | 🇹🇷 **Turkish** | `Auto-Generated` | Needs manual native review and polishing |
| `nl` | 🇳🇱 **Dutch** | `Auto-Generated` | Needs manual native review and polishing |
| `ja` | 🇯🇵 **Japanese** | `Auto-Generated` | Needs manual native review and polishing |
| `ko` | 🇰🇷 **Korean** | `Auto-Generated` | Needs manual native review and polishing |
| `pt` | 🇵🇹 **European Portuguese** | `Auto-Generated` | Needs manual native review and polishing |
> [!WARNING]
> **Autogeneration Quality Rule**
> Any newly contributed languages must be committed as `"Auto-Generated"` until fully reviewed and signed off by a native speaker in a pull request.
---
## ⚖️ Strict Legal Exclusion Rule
Our legal pages have strict constraints to protect user privacy and avoid regulatory liabilities.
> [!IMPORTANT]
> **DO NOT TRANSLATE LEGAL DOCUMENTS**
> The legal notice ([impressum.html](file:///Users/koala/Documents/KoalaPlay/website/impressum.html)) and privacy policy ([datenschutz.html](file:///Users/koala/Documents/KoalaPlay/website/datenschutz.html)) **MUST remain exclusively in English and German**.
>
> * **Rationale:** Legal compliance under the European Union General Data Protection Regulation (GDPR) and the German Digital Services Act (DDG). Offering automated translations of legally binding notices introduces compliance risks due to potential mistranslations of liability limits.
> * **Technical Fallback:** The dynamic initializer script (`lang-init.js`) is configured to automatically fallback to **English** for legal pages if a user visits them with a French, Spanish, or other unsupported language preference, keeping their dynamic dropdown choice intact for homepage links.
---
## 🛠️ Step-by-Step: Adding a New Language
Adding a new language (e.g., Italian - `it`) is straightforward. Follow these four structured steps:
### Step 1: Create the Translation Dictionary
Create a new JSON file inside the locales directory named `[lang].json` (e.g., `website/locales/it.json`).
1. Copy the structure of [`website/locales/en.json`](file:///Users/koala/Documents/KoalaPlay/website/locales/en.json) to use as your baseline.
2. Translate all string values while keeping the JSON keys identical.
3. Configure the language metadata keys at the top of the file:
```json
{
"LANG_CODE": "it",
"HTML_CLASS": "lang-it",
"CANONICAL_PATH": "it/",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN"
}
```
### Step 2: Register in the Compiler
Open the static site generator script [`website/build.js`](file:///Users/koala/Documents/KoalaPlay/website/build.js) and append your new language code to the active `languages` array:
```javascript
// Add 'it' to the array
const languages = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it'];
```
### Step 3: Run the Build Script
Execute the compiler from the root of the repository:
```bash
node website/build.js
```
The compiler will automatically:
1. Load the new JSON translation file.
2. Create the target subdirectory `/website/www/it/`.
3. Generate the compiled `/website/www/it/index.html` landing page, injecting correct relative assets and canonical metadata.
### Step 4: Update the Dashboard
Open this `TRANSLATION.md` file and add your language to the **Supported Languages Dashboard** table, marking it as `Auto-Generated` (unless manually verified).
---
## 🔮 Future Roadmap: Dynamic Utility Pages
For pages that require fully dynamic, client-side interactions (like the room invitation bridge [`join.html`](file:///Users/koala/Documents/KoalaPlay/website/join.html)), we need to scale to unlimited languages without bloating the HTML size or polluting the URL.
### Clean Client-Side i18n Architecture
To maintain zero URL pollution (e.g. keeping invitation links clean as `/join.html#join:roomID:password`), we propose an **asynchronous JSON dictionary injection architecture**:
#### 1. Page Lifecycle Flow
1. **User Landing:** The guest enters `/join.html` with a shared hash.
2. **Language Resolution:** `lang-init.js` immediately reads their saved preference (`localStorage` or `navigator.language`) and applies the active class (e.g. `html.lang = "es"`).
3. **Async Fetching:** A client-side loader script (`i18n-client.js`) runs asynchronously, downloading the correct dictionary (`fetch("/locales/es.json")`).
4. **DOM Translation:** The script scans the page for elements carrying a `data-i18n` attribute and safely updates their text content at runtime, avoiding dual-text nodes and stylesheet recalculations.
#### 2. Declarative HTML Markup
Elements are defined with custom data attributes specifying translation keys. English text is placed as the static HTML fallback:
```html
<h1 data-i18n="JOIN_TITLE">Ready to sync?</h1>
<p id="join-desc" data-i18n="JOIN_SUBTITLE">You've been invited to join a session.</p>
```
#### 3. Zero-Dependency Engine (`i18n-client.js`)
```javascript
document.addEventListener('DOMContentLoaded', async () => {
// 1. Recover the language determined during early initialization
const activeLang = document.documentElement.lang || 'en';
if (activeLang === 'en') return; // Default markup is already in English
// 2. Fetch the corresponding locale JSON asynchronously
try {
const response = await fetch(`locales/${activeLang}.json`);
if (!response.ok) throw new Error('Locale file unavailable');
const dictionary = await response.json();
// 3. Scan and translate data-i18n attributes
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (dictionary[key]) {
if (el.tagName === 'IMG') {
el.alt = dictionary[key];
} else {
el.textContent = dictionary[key];
}
}
});
} catch (err) {
console.warn('Dynamic i18n loading failed. Defaulting to English:', err);
}
});
```
#### Core Benefits
* **Zero URL Pollution:** Keeps invitation hashes private and avoids messy query parameters (`?lang=de`), protecting user privacy.
* **Optimal Performance:** Eliminates duplicate hidden text blocks, cutting page weight in half and ensuring smooth rendering.
* **Infinite Scale:** Adding new languages to dynamic pages requires zero edits to HTML markup; the engine simply fetches new JSON dictionaries on-demand.
---
## 🔌 Extension Internationalization (i18n)
In **v2.0**, we extended full internationalization support to the **Browser Extension itself**. The architecture mirrors our web-based dynamic localization model to maintain complete parity.
* **Locales Directory:** [`extension/locales/`](file:///Users/koala/Documents/KoalaPlay/extension/locales/)
* **Active Dictionaries:**
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (🇬🇧 Baseline English)
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (🇩🇪 German)
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (🇫🇷 French)
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (🇪🇸 Spanish)
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (🇧🇷 Portuguese (Brasil))
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (🇷🇺 Russian)
* [`it.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/it.json) (🇮🇹 Italian)
* [`pl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pl.json) (🇵🇱 Polish)
* [`tr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/tr.json) (🇹🇷 Turkish)
* [`nl.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/nl.json) (🇳🇱 Dutch)
* [`ja.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ja.json) (🇯🇵 Japanese)
* [`ko.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ko.json) (🇰🇷 Korean)
* [`pt.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt.json) (🇵🇹 European Portuguese)
* **Translation Engine:** [`extension/i18n.js`](file:///Users/koala/Documents/KoalaPlay/extension/i18n.js)
* **Validation Script:** [`scripts/test-locales.js`](file:///Users/koala/Documents/KoalaPlay/scripts/test-locales.js)
### ⚙️ How it Works inside the Extension
1. **System Locale Auto-Detection**: On first run, the extension detects the browser system language using `navigator.language` or `chrome.i18n.getUILanguage()`.
2. **On-the-Fly Redraws**: When the user selects a different language in the settings tab (`#langSelector`), the selection is stored in `chrome.storage.sync` and the translation engine immediately triggers `translateDOM()`. The interface, empty state cards, tooltips, dynamic onboarding tutorial guides, and status badges re-render instantly without reloading the popup.
3. **Localized System Notifications**: On play, pause, or seek commands, `background.js` retrieves the user's active locale preference from storage, loads the correct dictionary, and pushes native OS notifications fully translated.
### 🧪 Auditing & Sync Checks
To ensure that no language dictionary falls out of sync (causing missing labels or blank interfaces), developers must run the locale auditor tool before packaging releases:
```bash
node scripts/test-locales.js
```
This script asserts that all JSON dictionary files under `extension/locales/` share exactly the same set of keys as the English baseline (`en.json`).
+39 -34
View File
@@ -19,20 +19,39 @@ document.addEventListener('DOMContentLoaded', () => {
// Scroll Reveal Logic (IntersectionObserver for performance)
const revealElements = document.querySelectorAll('[data-reveal]');
const revealObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('revealed');
revealObserver.unobserve(entry.target);
}
});
}, {
rootMargin: '0px 0px -150px 0px',
threshold: 0.1
});
revealElements.forEach(el => revealObserver.observe(el));
if ('IntersectionObserver' in window) {
const revealObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('revealed');
revealObserver.unobserve(entry.target);
}
});
}, {
rootMargin: '0px 0px -150px 0px',
threshold: 0.1
});
revealElements.forEach(el => revealObserver.observe(el));
} else {
// Fallback: without IntersectionObserver support, reveal everything
// immediately so no content can ever stay hidden.
revealElements.forEach(el => el.classList.add('revealed'));
}
// Auto-update URL hash as user scrolls through sections
// (preserves position across language switches)
if ('IntersectionObserver' in window) {
const sectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
history.replaceState(null, null, '#' + entry.target.id);
}
});
}, { threshold: 0.3 });
document.querySelectorAll('section[id], header[id]').forEach(el => sectionObserver.observe(el));
}
// Navbar scroll effect
const nav = document.querySelector('nav');
@@ -46,20 +65,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Smooth scroll for anchors
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Invite Detection & Bridge
const checkInvite = () => {
const isJoinPage = window.location.pathname.includes('join');
@@ -475,11 +480,11 @@ document.addEventListener('DOMContentLoaded', () => {
const activeLang = safeGetLocalStorage('koala_lang') || (navigator.language.startsWith('de') ? 'de' : 'en');
const path = window.location.pathname;
const pathSegments = path.split('/');
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'].includes(seg));
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'].includes(seg));
// Only need to do this dynamic rewrite if we are NOT already inside a localized subdirectory
if (!isSubdir) {
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="pt-BR/"], a[href="ru/"], a[href="it/"], a[href="pl/"], a[href="tr/"], a[href="nl/"], a[href="ja/"], a[href="ko/"], a[href="pt/"]');
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="it/"], a[href="nl/"], a[href="pl/"], a[href="pt/"], a[href="pt-BR/"], a[href="tr/"], a[href="ru/"], a[href="ja/"], a[href="ko/"], a[href="zh/"], a[href="uk/"]');
homeLinks.forEach(link => {
link.href = (activeLang === 'en') ? './' : `${activeLang}/`;
});
@@ -508,7 +513,7 @@ document.addEventListener('DOMContentLoaded', () => {
target = hasHtml ? 'imprint.html' : 'imprint';
if (path.includes('/de/')) target = hasHtml ? '../imprint.html' : '../imprint';
}
window.location.href = target;
window.location.href = target + window.location.hash;
return;
} else if (isLegalPrivacy) {
let target;
@@ -520,7 +525,7 @@ document.addEventListener('DOMContentLoaded', () => {
target = hasHtml ? 'privacy.html' : 'privacy';
if (path.includes('/de/')) target = hasHtml ? '../privacy.html' : '../privacy';
}
window.location.href = target;
window.location.href = target + window.location.hash;
return;
}
@@ -530,7 +535,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (isIndex) {
// Static navigation: Route to correct subdirectory
const pathSegments = path.split('/');
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'].includes(seg));
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'].includes(seg));
let targetPath;
if (newLang === 'en') {
@@ -549,11 +554,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
window.location.href = targetPath;
window.location.href = targetPath + window.location.hash;
} else {
// Dynamic page: Toggle classes and update elements dynamically without navigating away
const html = document.documentElement;
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-pt-br', 'lang-ru', 'lang-it', 'lang-pl', 'lang-tr', 'lang-nl', 'lang-ja', 'lang-ko', 'lang-pt');
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-it', 'lang-nl', 'lang-pl', 'lang-pt', 'lang-pt-br', 'lang-tr', 'lang-ru', 'lang-ja', 'lang-ko', 'lang-zh', 'lang-uk');
// Fallback dynamic pages to 'en' if 'de' is not chosen (since fr/es markup is not present)
const activeDisplayLang = (newLang === 'de') ? 'de' : 'en';
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -162,7 +162,7 @@ async function compile() {
}
const templateContent = fs.readFileSync(templatePath, 'utf8');
const localesDir = path.join(websiteDir, 'locales');
const languages = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it', 'pl', 'tr', 'nl', 'ja', 'ko', 'pt'];
const languages = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'];
// Read version for build-time injection (SEO: crawlers see real version)
const versionJson = JSON.parse(fs.readFileSync(path.join(websiteDir, 'version.json'), 'utf8'));
+5
View File
@@ -7,6 +7,11 @@
<link rel="preload" href="../style.min.css" as="style">
<link rel="stylesheet" href="../style.min.css">
<link rel="icon" type="image/webp" href="../assets/NewLogoIcon_64.webp">
<link rel="canonical" href="https://sync.koalastuff.net/de/datenschutz">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/privacy">
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/datenschutz">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/privacy">
<meta name="description" content="Datenschutzerklärung von KoalaSync - Wir nehmen Datenschutz ernst. Erfahre mehr über unseren Ansatz ohne Tracking, ohne Logs und ohne Cookies.">
<meta name="robots" content="index, follow">
<script type="application/ld+json">
+5
View File
@@ -7,6 +7,11 @@
<link rel="preload" href="../style.min.css" as="style">
<link rel="stylesheet" href="../style.min.css">
<link rel="icon" type="image/webp" href="../assets/NewLogoIcon_64.webp">
<link rel="canonical" href="https://sync.koalastuff.net/de/impressum">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
<meta name="description" content="Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.">
<meta name="robots" content="index, follow">
<script type="application/ld+json">
+5
View File
@@ -7,6 +7,11 @@
<link rel="preload" href="style.min.css" as="style">
<link rel="stylesheet" href="style.min.css">
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
<link rel="canonical" href="https://sync.koalastuff.net/imprint">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
<meta name="description" content="Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.">
<meta name="robots" content="index, follow">
<script type="application/ld+json">
+5 -1
View File
@@ -31,11 +31,15 @@
'tr': 'tr',
'nl': 'nl',
'ja': 'ja',
'ko': 'ko'
'ko': 'ko',
'zh': 'zh',
'uk': 'uk'
};
var getBrowserLang = function() {
var fullLang = (navigator.language || '').toLowerCase();
if (fullLang.indexOf('zh') === 0) return 'zh';
if (fullLang.indexOf('uk') === 0) return 'uk';
if (fullLang.indexOf('pt-br') === 0) return 'pt-br';
if (fullLang.indexOf('pt') === 0) return 'pt';
return fullLang.split('-')[0];
+3 -2
View File
@@ -7,6 +7,7 @@
"OG_LOCALE": "de_DE",
"META_TITLE": "KoalaSync | Netflix, YouTube & jedes Video mit Freunden synchronisieren",
"META_DESCRIPTION": "Schaue Netflix, YouTube, Twitch und jedes HTML5-Video synchron mit Freunden. Kostenlose, quelloffene Browser-Erweiterung. Keine Anmeldung erforderlich.",
"SCHEMA_APP_DESC": "Schaue Netflix, YouTube, Twitch und jedes HTML5-Video synchron mit Freunden. Kostenlose, quelloffene Browser-Erweiterung. Keine Anmeldung erforderlich.",
"OG_TITLE": "KoalaSync | Netflix, Emby, Jellyfin & fast jedes Video mit Freunden synchronisieren",
"OG_DESCRIPTION": "Schaue Netflix, Emby, Jellyfin, YouTube, Twitch und fast jedes HTML5-Video perfekt synchronisiert. Quelloffene, datenschutzfreundliche Browser-Erweiterung für Chrome und Firefox.",
"TWITTER_TITLE": "KoalaSync | Netflix, Emby, Jellyfin & fast jedes Video mit Freunden synchronisieren Browser-Erweiterung",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "Nur unterstützte Seiten",
"COMP_FEAT_6_NAME": "Lokalisierung / Übersetzung",
"COMP_FEAT_6_DESC": "Verfügbare Oberflächensprachen und Übersetzungsunterstützung.",
"COMP_FEAT_6_KOALA": "13 Sprachen (wachsend)",
"COMP_FEAT_6_KOALA": "15 Sprachen (wachsend)",
"COMP_FEAT_6_TELE": "Nur Englisch",
"COMP_FEAT_7_NAME": "Audio Compressor / Lautstärke Angleichen",
"COMP_FEAT_7_DESC": "Integrierte Audionormalisierung für leise Dialoge und laute Effekte",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "de/impressum",
"FOOTER_PRIVACY_LINK": "de/datenschutz",
"FOOTER_DISCLAIMER": "KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.",
"FOOTER_SUPPORT": "Kauf mir einen Kaffee auf Ko-Fi"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+3 -2
View File
@@ -7,6 +7,7 @@
"OG_LOCALE": "en_US",
"META_TITLE": "KoalaSync | Sync Netflix, YouTube & Any Video with Friends",
"META_DESCRIPTION": "Watch Netflix, YouTube, Twitch & any HTML5 video in perfect sync with friends. Free, open-source browser extension for Chrome and Firefox. No sign-up needed.",
"SCHEMA_APP_DESC": "Watch Netflix, YouTube, Twitch & any HTML5 video in perfect sync with friends. Free, open-source browser extension for Chrome and Firefox. No sign-up needed.",
"OG_TITLE": "KoalaSync | Sync Netflix, Emby, Jellyfin & Almost Any Video with Friends",
"OG_DESCRIPTION": "Watch Netflix, Emby, Jellyfin, YouTube, Twitch and almost any HTML5 video in perfect sync. Privacy-first, open-source browser extension for Chrome & Firefox.",
"TWITTER_TITLE": "KoalaSync | Sync Netflix, Emby, Jellyfin & Almost Any Video with Friends Browser Extension",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "Supported sites only",
"COMP_FEAT_6_NAME": "Localization / Translation",
"COMP_FEAT_6_DESC": "Available interface languages and translation support.",
"COMP_FEAT_6_KOALA": "13 Languages (and growing)",
"COMP_FEAT_6_KOALA": "15 Languages (and growing)",
"COMP_FEAT_6_TELE": "English only",
"COMP_FEAT_7_NAME": "Audio Compressor / Volume Leveling",
"COMP_FEAT_7_DESC": "Built-in audio normalization to balance quiet dialogue and loud effects",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.",
"FOOTER_SUPPORT": "Buy me a coffee on Ko-Fi"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+86 -85
View File
@@ -6,7 +6,8 @@
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "es_ES",
"META_TITLE": "KoalaSync | Sincroniza Netflix, YouTube y cualquier video con amigos",
"META_DESCRIPTION": "Mira Netflix, YouTube, Twitch y cualquier video HTML5 en perfecta sincronización con amigos. Extensión de navegador gratuita y de código abierto. Sin registro.",
"META_DESCRIPTION": "Mira Netflix, YouTube, Twitch y cualquier video HTML5 en perfecta sincronización con amigos. Extensión de navegador gratuita, de código abierto y sin necesidad de registro.",
"SCHEMA_APP_DESC": "Mira Netflix, YouTube, Twitch y cualquier video HTML5 en perfecta sincronización con amigos. Extensión de navegador gratuita, de código abierto y sin necesidad de registro.",
"OG_TITLE": "KoalaSync | Sincroniza Netflix, Emby, Jellyfin y casi cualquier video con amigos",
"OG_DESCRIPTION": "Mira Netflix, Emby, Jellyfin, YouTube, Twitch y casi cualquier video HTML5 en perfecta sincronización. Extensión de navegador de código abierto y respetuosa con la privacidad para Chrome y Firefox.",
"TWITTER_TITLE": "KoalaSync | Sincroniza Netflix, Emby, Jellyfin y casi cualquier video con amigos Extensión de navegador",
@@ -14,7 +15,7 @@
"NAV_FEATURES": "Características",
"NAV_HOW_IT_WORKS": "Cómo funciona",
"HERO_TITLE": "Miren juntos.<span class='hero-line2'>Sincronización perfecta.</span>",
"HERO_SUBTITLE": "Tu noche de películas a distancia sin retrasos. Sin registro ni recopilación de datos. Solo comparte un enlace y miren juntos.",
"HERO_SUBTITLE": "Tu noche de películas a distancia sin retrasos. Sin registros ni recopilación de datos. Solo comparte un enlace y miren juntos.",
"HERO_MASCOT_ALT": "Un lindo koala de pie mirando hacia abajo a los botones de descarga",
"ADD_TO_CHROME": "Añadir a Chrome",
"ADD_TO_FIREFOX": "Añadir a Firefox",
@@ -25,40 +26,40 @@
"USE_CASES_SUBTITLE": "Ya sea cerca o lejos, KoalaSync une a las personas en torno a sus videos favoritos.",
"USE_CASE_1_ALT": "Dos lindos koalas sentados juntos compartiendo un cubo de palomitas de maíz",
"USE_CASE_1_TITLE": "Noche de películas con amigos",
"USE_CASE_1_DESC": "Sincroniza tus películas en tiempo real y habla por Discord, Zoom o tu aplicación de llamada de voz favorita. Es como estar en la misma habitación.",
"USE_CASE_1_DESC": "Sincroniza tus películas en tiempo real y habla por Discord, Zoom o tu aplicación de voz favorita. Es como estar en la misma habitación.",
"USE_CASE_2_ALT": "Un profesor koala presentando en una pantalla a dos estudiantes koala remotos",
"USE_CASE_2_TITLE": "Aprendizaje a distancia",
"USE_CASE_2_DESC": "Analiza tutoriales en línea, conferencias o transmisiones de capacitación de desarrolladores con compañeros o colegas. Pausa y discute pasos complejos en perfecta armonía.",
"USE_CASE_2_DESC": "Analiza tutoriales en línea, conferencias o directos de programación con compañeros o colegas. Pausa y discute pasos complejos en perfecta armonía.",
"USE_CASE_3_ALT": "Una linda pareja de koalas viendo juntos de forma remota; uno está sentado frente a una PC y el otro en la cama con una computadora portátil, con corazones volando entre ellos",
"USE_CASE_3_TITLE": "Relaciones a larga distancia",
"USE_CASE_3_DESC": "Reduce la distancia y disfruta de noches de citas. Experimenta cada giro de la trama, ríete de los mismos chistes y comparte momentos emotivos en el mismo milisegundo exacto.",
"USE_CASE_3_DESC": "Reduce la distancia y disfruta de noches de citas. Vive cada giro de la trama, ríete de los mismos chistes y comparte momentos emotivos en el mismo milisegundo exacto.",
"WHY_TITLE": "¿Por qué KoalaSync?",
"WHY_SUBTITLE": "Diseñado para una sincronización confiable, privacidad y una configuración sencilla.",
"WHY_SUBTITLE": "Diseñado para una sincronización confiable, con la privacidad como prioridad y una configuración sencilla.",
"FEATURE_1_TITLE": "Control total / Sincronización instantánea",
"FEATURE_1_DESC": "Uno pausa, todos pausan. Uno avanza, todos siguen. Nuestro protocolo de sincronización en dos fases coordina la reproducción en tiempo real para todos los participantes.",
"FEATURE_1_DESC": "Si uno pausa, todos pausan. Si uno avanza, todos le siguen. Nuestro protocolo de sincronización en dos fases coordina la reproducción en tiempo real para todos los participantes.",
"FEATURE_2_TITLE": "Maratones sin fin",
"FEATURE_2_DESC": "Reproducción automática sincronizada. KoalaSync detecta automáticamente transiciones de episodios y suspende la reproducción hasta que todos los participantes hayan cargado el siguiente video.",
"FEATURE_2_DESC": "Reproducción automática sincronizada. KoalaSync detecta automáticamente los cambios de episodio y detiene la reproducción hasta que todos los participantes hayan cargado el siguiente video.",
"FEATURE_3_TITLE": "Sin cuentas / Privacidad absoluta",
"FEATURE_3_DESC": "Sin registros, sin seguimiento, sin almacenamiento de datos. El servidor funciona completamente en RAM efímera y elimina tu sala tan pronto como sales.",
"FEATURE_3_DESC": "Sin registros, sin seguimiento y sin dejar huella. El servidor funciona completamente en RAM efímera y elimina tu sala en cuanto te vas.",
"FEATURE_4_TITLE": "Soporte universal HTML5",
"FEATURE_4_DESC": "Funciona en YouTube, Twitch, Netflix, Jellyfin, Emby y casi cualquier página web estándar que contenga un elemento de video HTML5. También es compatible con configuraciones personalizadas. Nunca se envían datos de vídeo a nuestros servidores, solo metadatos de sincronización.",
"FEATURE_4_DESC": "Funciona en YouTube, Twitch, Netflix, Jellyfin, Emby y casi cualquier página web estándar con un reproductor HTML5. También es compatible con configuraciones propias. Nunca enviamos datos de vídeo a nuestros servidores, solo metadatos de sincronización.",
"FEATURE_5_TITLE": "Auto-alojable y listo para Docker",
"FEATURE_5_DESC": "Nuestros servidores oficiales son gratuitos y rápidos, pero puedes tomar el control total si lo deseas. Inicia tu propio servidor de retransmisión privado en segundos a través de Docker.",
"FEATURE_6_TITLE": "Invitaciones al instante / Unirse en 1 clic",
"FEATURE_6_DESC": "Sin direcciones IP o contraseñas que intercambiar. Comparte un enlace de invitación generado para permitir que tus amigos se unan automáticamente a tu sala con un solo clic.",
"FEATURE_5_DESC": "Nuestros servidores oficiales son gratuitos y rápidos, pero puedes tomar el control total si lo deseas. Inicia tu propio servidor de retransmisión privado en segundos mediante Docker.",
"FEATURE_6_TITLE": "Invitaciones al instante / Unirse con un clic",
"FEATURE_6_DESC": "Sin intercambio de direcciones IP o contraseñas. Comparte un enlace de invitación generado para que tus amigos se unan automáticamente a tu sala con un solo clic.",
"FEATURE_7_TITLE": "Nivelación de volumen / Compresor de audio",
"FEATURE_7_DESC": "No más usar el control de volumen constantemente. El compresor de audio integrado realza los diálogos bajos y controla las explosiones fuertes. Funciona en Netflix, Prime Video, Disney+, YouTube y cualquier video HTML5. Completamente local ningún dato de audio sale de tu ordenador.",
"FEATURE_8_TITLE": "Open Source (MIT License)",
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
"FEATURE_7_DESC": "Se acabó el tener que ajustar el volumen constantemente. El compresor de audio integrado realza los diálogos bajos y suaviza las explosiones fuertes. Funciona en Netflix, Prime Video, Disney+, YouTube y cualquier video HTML5. Funciona de forma local: ningún dato de audio sale de tu equipo.",
"FEATURE_8_TITLE": "Código Abierto (Licencia MIT)",
"FEATURE_8_DESC": "Todo el código fuente es público y auditable en GitHub. Sin cajas negras ni software propietario: transparencia total desde el servidor de retransmisión hasta la extensión del navegador.",
"COMP_TITLE": "KoalaSync vs Teleparty",
"COMP_SUBTITLE": "Descubre por qué una herramienta de código abierto, sin anuncios y respetuosa con la privacidad es la mejor opción para ver juntos.",
"COMP_SUBTITLE": "Descubre por qué una herramienta de código abierto, sin anuncios y privada es la mejor opción para ver contenido juntos.",
"COMP_COL_FEATURE": "Característica",
"COMP_FEAT_1_NAME": "Costo / Funciones de pago",
"COMP_FEAT_1_NAME": "Coste / Funciones de pago",
"COMP_FEAT_1_DESC": "Suscripciones premium, tarifas ocultas o funciones bloqueadas.",
"COMP_FEAT_1_KOALA": "100% Gratis",
"COMP_FEAT_1_TELE": "Suscripciones de pago / Bloqueos Premium",
"COMP_FEAT_2_NAME": "Licence / Code auditable",
"COMP_FEAT_2_DESC": "Si el código fuente es abierto y libremente auditable.",
"COMP_FEAT_1_TELE": "Niveles de pago / Funciones Premium",
"COMP_FEAT_2_NAME": "Licencia / Código auditable",
"COMP_FEAT_2_DESC": "Indica si el código fuente es abierto y libremente auditable.",
"COMP_FEAT_2_KOALA": "Código Abierto (MIT)",
"COMP_FEAT_2_TELE": "Propietario",
"COMP_FEAT_3_NAME": "Auto-alojamiento",
@@ -69,111 +70,111 @@
"COMP_FEAT_4_DESC": "Requisitos de registro, cookies de seguimiento y análisis.",
"COMP_FEAT_4_KOALA": "Sin persistencia (solo RAM)",
"COMP_FEAT_4_TELE": "Google Analytics y Cookies",
"COMP_FEAT_5_NAME": "Compatibilidad del sitio",
"COMP_FEAT_5_NAME": "Compatibilidad de sitios",
"COMP_FEAT_5_DESC": "Sitios web compatibles y compatibilidad de reproductores.",
"COMP_FEAT_5_KOALA": "Casi cualquier video HTML5",
"COMP_FEAT_5_TELE": "Solo sitios compatibles",
"COMP_FEAT_6_NAME": "Localización / Traducción",
"COMP_FEAT_6_DESC": "Idiomas de interfaz disponibles y soporte de traducción.",
"COMP_FEAT_6_KOALA": "13 idiomas (y creciendo)",
"COMP_FEAT_6_KOALA": "15 idiomas (y sumando)",
"COMP_FEAT_6_TELE": "Solo inglés",
"COMP_FEAT_7_NAME": "Compresor de Audio / Nivelación de Volumen",
"COMP_FEAT_7_DESC": "Normalización de audio integrada para equilibrar diálogos bajos y efectos fuertes",
"COMP_FEAT_7_KOALA": "Sí (4 ajustes preestablecidos, control dry/wet)",
"COMP_FEAT_7_KOALA": "Sí (4 ajustes, control dry/wet)",
"COMP_FEAT_7_TELE": "No",
"COMP_FOOTNOTE_1": "Estado de la comparación: mayo de 2026.",
"COMP_FOOTNOTE_2": "Detalles oficiales de precios de Teleparty Premium y redes compatibles:",
"COMP_FOOTNOTE_3": "Políticas de privacidad y recopilación de datos de seguimiento de Teleparty:",
"COMP_FOOTNOTE_4": "Funciona en sitios web que permiten inyecciones de scripts en etiquetas de video HTML5 estándar. Los sitios con políticas de seguridad de contenido (CSP) muy estrictas, protección de copia DRM o contenedores de reproductores fuertemente ocultos (como DOMs de sombra complejos) pueden restringir el control automatizado o la inyección.",
"COMP_FOOTNOTE_3": "Políticas de privacidad y recopilación de datos de Teleparty:",
"COMP_FOOTNOTE_4": "Funciona en sitios web que permiten inyecciones de scripts en etiquetas de video HTML5 estándar. Los sitios con políticas de seguridad (CSP) muy estrictas, protección DRM o reproductores ofuscados (como DOMs de sombra complejos) pueden restringir el control automático.",
"STEPS_TITLE": "Cómo empezar",
"STEP_1_TITLE": "Instalar la extensión",
"STEP_1_DESC": "Añade KoalaSync a tu navegador desde Chrome Web Store, complementos de Firefox o descarga el último archivo ZIP de desarrollador desde GitHub.",
"STEP_1_ILLUS_DESC": "Sincronizador de video respetuoso con la privacidad",
"STEP_1_DESC": "Añade KoalaSync a tu navegador desde la Chrome Web Store, complementos de Firefox o descarga el archivo ZIP para desarrolladores desde GitHub.",
"STEP_1_ILLUS_DESC": "Sincronizador de video privado",
"STEP_1_ILLUS_DL_CHROME": "Descargar para Chrome",
"STEP_1_ILLUS_DL_FIREFOX": "Descargar para Firefox",
"STEP_1_ILLUS_ACTIVE": "Extensión activa",
"STEP_1_ILLUS_READY": "¡Listo para ver juntos!",
"STEP_2_TITLE": "Crear una sala",
"STEP_2_DESC": "Abre la ventana de la extensión y haz clic en '+ Crear nueva sala'. KoalaSync genera automáticamente un identificador de sala y una contraseña seguros, se conecta y copia el enlace de invitación a tu portapapeles.",
"STEP_2_DESC": "Abre el menú de la extensión y haz clic en '+ Crear nueva sala'. KoalaSync genera automáticamente un ID de sala y una contraseña seguros, se conecta y copia el enlace al portapapeles.",
"STEP_2_ILLUS_ROOM": "Sala",
"STEP_2_ILLUS_SYNC": "Sincro",
"STEP_2_ILLUS_SETTINGS": "Opciones",
"STEP_2_ILLUS_SETTINGS": "Ajustes",
"STEP_2_ILLUS_CREATE": "+ Crear nueva sala",
"STEP_2_ILLUS_MANUAL": "Conexión manual / Avanzado",
"STEP_2_ILLUS_COPIED": "¡Enlace de invitación copiado!",
"STEP_3_TITLE": "Compartir y Sincronizar",
"STEP_3_DESC": "Envía el enlace de invitación a tus amigos. Tan pronto como se unan, selecciona tu pestaña de video y disfruta de la reproducción sincronizada.",
"STEP_3_DESC": "Envía el enlace de invitación a tus amigos. Una vez que se unan, selecciona tu pestaña de video y disfrutad de la reproducción sincronizada.",
"STEP_3_ILLUS_IN_SYNC": "SINCRONIZADO",
"SELF_TITLE": "Para auto-alojadores",
"SELF_SUBTITLE": "¿No confías en nuestro servidor? Mantén la soberanía total de los datos. Despliega tu propio servidor de retransmisión en minutos.",
"SELF_MASCOT_ALT": "Un lindo koala sentado frente a una computadora portátil desplegando un contenedor Docker para auto-alojamiento",
"SELF_SUBTITLE": "¿No te fías de nuestro servidor? Mantén la soberanía total de tus datos. Despliega tu propio servidor de retransmisión en minutos.",
"SELF_MASCOT_ALT": "Un lindo koala sentado frente a un portátil desplegando un contenedor Docker",
"SELF_COPY_CODE": "Copiar código",
"SELF_GITHUB_PACKAGES": "Ver todas las etiquetas de imágenes en GitHub Packages",
"SELF_GITHUB_PACKAGES": "Ver todas las versiones en GitHub Packages",
"BOTTOM_TITLE": "¿Aún no te convence? Compruébalo tú mismo.",
"BOTTOM_SUBTITLE": "KoalaSync es 100% de código abierto, sin anuncios y sin seguimiento. Audita nuestra base de código en GitHub o instala la extensión del navegador directamente.",
"BOTTOM_MASCOT_ALT": "Un lindo koala sosteniendo una página de repositorio de GitHub junto a Octocat, la mascota de GitHub",
"BOTTOM_SUBTITLE": "KoalaSync es 100% de código abierto, sin anuncios y sin rastreadores. Audita nuestro código en GitHub o instala la extensión directamente.",
"BOTTOM_MASCOT_ALT": "Un lindo koala junto a Octocat, la mascota de GitHub",
"FAQ_TITLE": "Preguntas frecuentes",
"FAQ_SUBTITLE": "Todo lo que necesitas saber para ver videos juntos en perfecta sincronía.",
"FAQ_SUBTITLE": "Todo lo que necesitas saber para ver vídeos en perfecta sincronía.",
"FAQ_Q1": "¿Puedo ver Netflix, Emby o Jellyfin sincronizado con amigos?",
"FAQ_A1": "¡Sí! KoalaSync funciona con Netflix, YouTube, Twitch y cualquier sitio con reproductor de video HTML5 incluyendo Emby, Jellyfin y servidores multimedia propios. Instala la extensión, crea una sala, comparte el enlace y todos miran en perfecta sincronía.",
"FAQ_A1": "¡Sí! KoalaSync funciona en Netflix, YouTube, Twitch y cualquier web con reproductor HTML5, incluyendo Emby, Jellyfin y servidores multimedia propios. Instala la extensión, crea una sala, comparte el enlace y todos veréis lo mismo al mismo tiempo.",
"FAQ_Q2": "¿Es KoalaSync una alternativa gratuita a Teleparty?",
"FAQ_A2": "100% gratuito y de código abierto bajo licencia MIT. Sin niveles premium, sin funciones bloqueadas, sin anuncios. A diferencia de Teleparty, el servidor relay de KoalaSync solo usa RAM — sin recolección de datos ni cuentas de usuario.",
"FAQ_Q3": "¿Funciona KoalaSync con Emby, Jellyfin y servidores multimedia propios?",
"FAQ_A3": "Sí. KoalaSync detecta cualquier elemento de video HTML5, por lo que funciona con Emby, Jellyfin, Plex y cualquier configuración de streaming personalizada. También puedes alojar tu propio servidor relay con Docker.",
"FAQ_Q4": "¿Qué navegadores y plataformas son compatibles con KoalaSync?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave y todos los navegadores basados en Chromium. Instálalo desde Chrome Web Store o Firefox Add-ons.",
"FAQ_Q5": "¿Todos mis amigos necesitan instalar la extensión?",
"FAQ_A5": "Sí, cada participante necesita KoalaSync pero sin registro ni creación de cuenta. Solo haz clic en el enlace de invitación, instala la extensión si es necesario y te unes automáticamente a la sala.",
"FAQ_A2": "Es 100% gratuito y de código abierto (Licencia MIT). Sin suscripciones, sin funciones bloqueadas y sin anuncios. A diferencia de Teleparty, el servidor de KoalaSync solo usa RAM: no hay bases de datos ni cuentas de usuario.",
"FAQ_Q3": "¿Funciona KoalaSync con Emby, Jellyfin y servidores propios?",
"FAQ_A3": "Sí. KoalaSync detecta cualquier elemento de video HTML5 estándar, por lo que es perfecto para Emby, Jellyfin, Plex y cualquier plataforma de streaming personalizada. También puedes alojar tu propio servidor con Docker.",
"FAQ_Q4": "¿Qué navegadores y plataformas son compatibles?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave y todos los navegadores basados en Chromium. Instálalo directamente desde las tiendas oficiales sin configuraciones manuales.",
"FAQ_Q5": "¿Todos mis amigos necesitan tener la extensión instalada?",
"FAQ_A5": "Sí, cada participante necesita KoalaSync, pero no hace falta registrarse. Solo hay que hacer clic en el enlace, instalar la extensión si no se tiene y la unión a la sala es automática.",
"FAQ_Q6": "¿Están seguros mis datos? ¿Y si no confío en el servidor oficial?",
"FAQ_A6": "Totalmente seguro. El servidor relay de KoalaSync usa solo RAM todos los datos de sesión se eliminan al salir de la sala. Sin base de datos, sin registros, sin análisis, sin cuentas. Si prefieres control total: aloja tu propio servidor relay privado con Docker en minutos. El código fuente es completamente abierto y auditable en GitHub.",
"FAQ_Q7": "¿Puedo arreglar diálogos bajos y explosiones fuertes al ver videos en el navegador?",
"FAQ_A7": "Sí. KoalaSync 2.2.0 incluye un compresor de audio integrado que reduce la diferencia entre las partes bajas y altas de tu video. Funciona localmente a través de la API Web Audio ningún dato de audio sale de tu ordenador. Abre el popup, crea una sala, ve a Ajustes → Audio Processing, activa el compresor y elige un preset. Funciona en Netflix, Prime Video, Disney+, YouTube y cualquier sitio de video HTML5.",
"FAQ_A6": "Totalmente seguros. El servidor de KoalaSync funciona solo en RAM: todos los datos de la sesión se borran permanentemente en cuanto sales de la sala. Sin registros ni analíticas. Si prefieres el control total, puedes auto-alojar tu propio servidor privado mediante Docker.",
"FAQ_Q7": "¿Puedo arreglar los diálogos bajos y las explosiones fuertes al ver vídeos?",
"FAQ_A7": "Sí. KoalaSync incluye un compresor de audio integrado que equilibra las partes bajas y altas del sonido. Funciona localmente mediante la Web Audio API: ningún dato de audio sale de tu equipo. Ve a Configuración → Procesamiento de audio y activa el compresor.",
"HOWTO_TITLE": "Cómo sincronizar videos con amigos usando KoalaSync",
"HOWTO_DESC": "Mira videos en perfecta sincronía con amigos en YouTube, Netflix, Twitch y más. Sin registro, sin seguimiento.",
"HOWTO_STEP_1_NAME": "Instalar la extensión",
"HOWTO_STEP_1_TEXT": "Añade KoalaSync a tu navegador desde Chrome Web Store o Firefox Add-ons. Funciona en cualquier sitio con un elemento de video.",
"HOWTO_STEP_2_NAME": "Crear una sala",
"HOWTO_STEP_2_TEXT": "Abre la extensión y haz clic en Crear nueva sala. El enlace de invitación se copia automáticamente al portapapeles.",
"HOWTO_STEP_3_NAME": "Compartir y sincronizar",
"HOWTO_STEP_3_TEXT": "Envía el enlace de invitación a tus amigos. Selecciona una pestaña de video y disfruta de reproducción sincronizada en tiempo real.",
"HOWTO_DESC": "Mira cualquier video en sincronía con amigos en YouTube, Netflix, Twitch y más. Sin registros ni rastreo.",
"HOWTO_STEP_1_NAME": "Instala la extensión",
"HOWTO_STEP_1_TEXT": "Añade KoalaSync a tu navegador desde la Chrome Web Store o Firefox Add-ons. Funciona en cualquier web con reproductor de vídeo.",
"HOWTO_STEP_2_NAME": "Crea una sala",
"HOWTO_STEP_2_TEXT": "Abre la extensión y haz clic en 'Crear nueva sala'. El enlace de invitación se copia automáticamente.",
"HOWTO_STEP_3_NAME": "Comparte y sincroniza",
"HOWTO_STEP_3_TEXT": "Envía el enlace a tus amigos. Selecciona la pestaña del video y disfrutad de la reproducción en tiempo real.",
"FOOTER_MIT": "Código abierto bajo la Licencia MIT.",
"FOOTER_RAM": "No se almacenan datos en nuestros servidores. Retransmisión solo en RAM.",
"FOOTER_LEGAL": "Legal Notice",
"FOOTER_PRIVACY": "Privacy Policy",
"FOOTER_RAM": "No almacenamos datos. Retransmisión pura basada en RAM.",
"FOOTER_LEGAL": "Aviso Legal",
"FOOTER_PRIVACY": "Política de Privacidad",
"MOCK_01": "Sala activa",
"MOCK_02": "Servidor oficial",
"MOCK_03": "Enlace de invitación",
"MOCK_04": "Participantes",
"MOCK_04": "Participantes en la sala",
"MOCK_05": "TÚ",
"MOCK_06": "Reproduciendo",
"MOCK_07": "Compañero",
"MOCK_08": "Salir",
"MOCK_07": "Participante",
"MOCK_08": "Salir de la sala",
"MOCK_09": "Seleccionar video",
"MOCK_10": "Control remoto",
"MOCK_11": "Reproducir",
"MOCK_12": "Pausa",
"MOCK_13": "Saltar a otros",
"MOCK_14": "Última actividad",
"MOCK_15": "Esperando a 1 compañero (KoalaPC)...",
"MOCK_16": "Saltar y reproducir",
"MOCK_17": "Tu nombre",
"MOCK_18": "Ocultar pestañas",
"MOCK_19": "Auto-sync siguiente episodio",
"MOCK_20": "Auto-copiar enlace",
"MOCK_21": "Notificaciones",
"MOCK_22": "Solución de problemas",
"MOCK_23": "Regenerar ID",
"MOCK_24": "Usa esto si ves error de \"Identidad duplicada\".",
"MOCK_25": "Estado de conexión",
"MOCK_26": "Conectado",
"MOCK_27": "Copiar logs",
"MOCK_28": "Info de depuración de video",
"MOCK_29": "Historial completo",
"MOCK_30": "Registros (últimos 50)",
"MOCK_31": "BORRAR",
"MOCK_32": "CLEAR",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Reproducir",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pausar",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SINCRO",
"MOCK_14": "Ir a la posición de otros",
"MOCK_15": "Estado de la última actividad",
"MOCK_16": "Esperando a 1 participante (KoalaPC)...",
"MOCK_17": "Omitir y reproducir ya",
"MOCK_18": "Tu nombre de usuario",
"MOCK_19": "Ocultar pestañas sin video",
"MOCK_20": "Sincro auto del siguiente episodio",
"MOCK_21": "Auto-copiar enlace",
"MOCK_22": "Notificaciones del navegador",
"MOCK_23": "Resolución de problemas",
"MOCK_24": "Regenerar ID de usuario",
"MOCK_25": "Usa esto si ves errores de \"Identidad duplicada\".",
"MOCK_26": "Estado de la conexión",
"MOCK_27": "Conectado",
"MOCK_28": "Copiar registros",
"MOCK_29": "Información de depuración de video",
"MOCK_30": "Historial completo",
"MOCK_31": "Registros (Últimos 50)",
"MOCK_32": "LIMPIAR",
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync no está afiliado, respaldado ni asociado con Netflix, Disney+, Amazon, YouTube, Twitch ni ninguna otra plataforma de streaming. Todas las marcas comerciales son propiedad de sus respectivos dueños.",
"FOOTER_SUPPORT": "Invítame a un café en Ko-Fi"
"FOOTER_DISCLAIMER": "KoalaSync no está afiliado, respaldado ni asociado con Netflix, Disney+, Amazon, YouTube, Twitch ni ninguna otra plataforma de streaming. Todas las marcas comerciales pertenecen a sus respectivos dueños.",
"FOOTER_SUPPORT": "Support KoalaSync"
}
+7 -6
View File
@@ -7,6 +7,7 @@
"OG_LOCALE": "fr_FR",
"META_TITLE": "KoalaSync | Synchronisez Netflix, YouTube et n'importe quelle vidéo avec vos amis",
"META_DESCRIPTION": "Regardez Netflix, YouTube, Twitch et des vidéos HTML5 en synchro avec vos amis. Extension de navigateur gratuite et open-source. Aucune inscription requise.",
"SCHEMA_APP_DESC": "Regardez Netflix, YouTube, Twitch et des vidéos HTML5 en synchro avec vos amis. Extension de navigateur gratuite et open-source. Aucune inscription requise.",
"OG_TITLE": "KoalaSync | Synchronisez Netflix, Emby, Jellyfin et presque toutes les vidéos avec vos amis",
"OG_DESCRIPTION": "Regardez Netflix, Emby, Jellyfin, YouTube, Twitch et presque n'importe quelle vidéo HTML5 en parfaite synchronisation. Extension de navigateur open-source et respectueuse de la vie privée pour Chrome et Firefox.",
"TWITTER_TITLE": "KoalaSync | Synchronisez Netflix, Emby, Jellyfin et presque toutes les vidéos avec vos amis Extension de navigateur",
@@ -49,7 +50,7 @@
"FEATURE_7_TITLE": "Égalisation du volume / Compresseur audio",
"FEATURE_7_DESC": "Fini de jouer avec le bouton du volume. Le compresseur audio intégré booste les dialogues discrets et adoucit les explosions. Fonctionne sur Netflix, Prime Video, Disney+, YouTube et toute vidéo HTML5. Entièrement local — aucune donnée audio ne quitte votre ordinateur.",
"FEATURE_8_TITLE": "Open Source (MIT License)",
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
"FEATURE_8_DESC": "Tout le code source est publiquement auditable sur GitHub. Pas de boite noire, pas de blob proprietaire - transparence totale du serveur relais a l'extension de navigateur.",
"COMP_TITLE": "KoalaSync vs Teleparty",
"COMP_SUBTITLE": "Découvrez pourquoi un outil open-source, sans publicité et respectueux de la vie privée est le meilleur choix pour regarder ensemble.",
"COMP_COL_FEATURE": "Fonctionnalité",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "Sites pris en charge uniquement",
"COMP_FEAT_6_NAME": "Localisation / Traduction",
"COMP_FEAT_6_DESC": "Langues d'interface disponibles et support de traduction.",
"COMP_FEAT_6_KOALA": "13 langues (et plus à venir)",
"COMP_FEAT_6_KOALA": "15 langues (et plus à venir)",
"COMP_FEAT_6_TELE": "Anglais uniquement",
"COMP_FEAT_7_NAME": "Compresseur Audio / Égalisation du Volume",
"COMP_FEAT_7_DESC": "Normalisation audio intégrée pour équilibrer les dialogues silencieux et les effets sonores",
@@ -150,9 +151,9 @@
"MOCK_08": "Quitter",
"MOCK_09": "Choisir la vidéo",
"MOCK_10": "Télécommande",
"MOCK_11": "Lire",
"MOCK_12": "Pause",
"MOCK_13": "Sauter vers les autres",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Lire",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pause",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SYNC",
"MOCK_14": "Dernière activité",
"MOCK_15": "En attente de 1 participant (KoalaPC)...",
"MOCK_16": "Passer & lire",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync n'est pas affilié à Netflix, Disney+, Amazon, YouTube, Twitch ou toute autre plateforme de streaming. Toutes les marques déposées appartiennent à leurs détenteurs respectifs.",
"FOOTER_SUPPORT": "Offre-moi un café sur Ko-Fi"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+137 -136
View File
@@ -2,178 +2,179 @@
"LANG_CODE": "it",
"HTML_CLASS": "lang-it",
"CANONICAL_PATH": "it/",
"LANG_TOGGLE_URL": "",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "it_IT",
"META_TITLE": "KoalaSync | Sincronizza Netflix, YouTube e qualsiasi video con gli amici",
"META_DESCRIPTION": "Guarda Netflix, YouTube, Twitch e qualsiasi video HTML5 in perfetta sincronia con gli amici. Estensione del browser gratuita e open source per Chrome e Firefox. Nessuna registrazione richiesta.",
"OG_TITLE": "KoalaSync | Sincronizza Netflix, Emby, Jellyfin e quasi tutti i video con gli amici",
"OG_DESCRIPTION": "Guarda Netflix, Emby, Jellyfin, YouTube, Twitch e quasi tutti i video HTML5 in perfetta sincronia. Estensione del browser open source e attenta alla privacy per Chrome e Firefox.",
"TWITTER_TITLE": "KoalaSync | Sincronizza Netflix, Emby, Jellyfin e quasi tutti i video con gli amici Estensione Browser",
"TWITTER_DESCRIPTION": "Guarda Netflix, Emby, Jellyfin, YouTube, Twitch e quasi tutti i video HTML5 in perfetta sincronia con gli amici. Estensione del browser open source e attenta alla privacy per Chrome e Firefox.",
"META_TITLE": "KoalaSync | Sincronizza Netflix, YouTube e qualsiasi video con i tuoi amici",
"META_DESCRIPTION": "Guarda Netflix, YouTube, Twitch e qualsiasi video HTML5 in perfetta sincronia. Estensione gratuita, open source e senza registrazione.",
"SCHEMA_APP_DESC": "Guarda Netflix, YouTube, Twitch e qualsiasi video HTML5 in perfetta sincronia. Estensione gratuita, open source e senza registrazione.",
"OG_TITLE": "KoalaSync | Sincronizza Netflix, Emby, Jellyfin e video web con i tuoi amici",
"OG_DESCRIPTION": "Guarda video in perfetta sincronia con i tuoi amici. Estensione open source per Chrome e Firefox incentrata sulla privacy.",
"TWITTER_TITLE": "KoalaSync | Sincronizza video in streaming con i tuoi amici",
"TWITTER_DESCRIPTION": "Guarda video insieme ai tuoi amici in perfetta sincronia. Open source e rispettoso della privacy.",
"NAV_FEATURES": "Funzionalità",
"NAV_HOW_IT_WORKS": "Come Funziona",
"HERO_TITLE": "Guarda Insieme.<span class='hero-line2'>Sincronizza Perfettamente.</span>",
"HERO_SUBTITLE": "La tua serata cinema a distanza senza lag. Nessuna registrazione, nessun dato raccolto. Condividi un link e guarda insieme.",
"HERO_MASCOT_ALT": "Un simpatico koala in piedi che guarda in basso verso i pulsanti di download",
"HERO_TITLE": "Guardate Insieme.<span class='hero-line2'>Sincronia Perfetta.</span>",
"HERO_SUBTITLE": "La tua serata cinema a distanza senza ritardi. Nessun account, nessun dato raccolto. Condividi un link e guardate insieme.",
"HERO_MASCOT_ALT": "Un simpatico koala che guarda i pulsanti di download",
"ADD_TO_CHROME": "Aggiungi a Chrome",
"ADD_TO_FIREFOX": "Aggiungi a Firefox",
"COMPAT_HEADING": "Funziona sulle tue piattaforme preferite",
"COMPAT_MORE": "e molti altri",
"COMPAT_TOOLTIP": "Funziona su quasi tutti i siti con un elemento video",
"USE_CASES_TITLE": "Perfetto per Ogni Occasione",
"COMPAT_HEADING": "Compatibile con le tue piattaforme preferite",
"COMPAT_MORE": "e molte altre",
"COMPAT_TOOLTIP": "Funziona su quasi tutti i siti con video HTML5",
"USE_CASES_TITLE": "Perfetto per ogni occasione",
"USE_CASES_SUBTITLE": "Vicini o lontani, KoalaSync unisce le persone attorno ai loro video preferiti.",
"USE_CASE_1_ALT": "Due simpatici koala seduti insieme che condividono un secchio di popcorn",
"USE_CASE_1_ALT": "Due koala che mangiano popcorn insieme",
"USE_CASE_1_TITLE": "Serata Cinema con gli Amici",
"USE_CASE_1_DESC": "Sincronizza i tuoi film in tempo reale e parla via Discord, Zoom o la tua app di chiamata vocale preferita. Sembrerà di essere nella stessa stanza.",
"USE_CASE_2_ALT": "Un professore koala che presenta su uno schermo a due studenti koala a distanza",
"USE_CASE_1_DESC": "Sincronizza i tuoi film in tempo reale e parla su Discord o Zoom. Sembrerà di essere nella stessa stanza.",
"USE_CASE_2_ALT": "Un professore koala che insegna a distanza",
"USE_CASE_2_TITLE": "Apprendimento a Distanza",
"USE_CASE_2_DESC": "Analizza tutorial online, lezioni o stream di formazione per sviluppatori insieme a compagni di classe o colleghi. Fai pausa e discuti i passaggi complessi in perfetta armonia.",
"USE_CASE_3_ALT": "Una simpatica coppia di koala che guarda insieme a distanza; uno è seduto al PC e l'altro è a letto con un laptop, con cuori che volano tra loro",
"USE_CASE_2_DESC": "Analizza tutorial o lezioni insieme a colleghi e compagni. Metti in pausa e discuti i passaggi più complessi in armonia.",
"USE_CASE_3_ALT": "Una coppia di koala che guarda video a distanza con dei cuori",
"USE_CASE_3_TITLE": "Relazioni a Distanza",
"USE_CASE_3_DESC": "Supera le distanze e goditi le serate romantiche. Vivi ogni colpo di scena, ridi alle stesse battute e condividi momenti emozionanti nello stesso identico millisecondo.",
"USE_CASE_3_DESC": "Accorcia le distanze e goditi serate romantiche. Vivi ogni emozione e ridi alle stesse battute nello stesso identico istante.",
"WHY_TITLE": "Perché KoalaSync?",
"WHY_SUBTITLE": "Progettato per una sincronizzazione affidabile, privacy al primo posto e configurazione semplice.",
"FEATURE_1_TITLE": "Controllo Totale / Sincronizzazione Istantanea",
"FEATURE_1_DESC": "Uno mette in pausa, tutti mettono in pausa. Uno cerca, tutti lo seguono. Il nostro protocollo di sincronizzazione a due fasi coordina la riproduzione in tempo reale tra tutti i partecipanti.",
"FEATURE_2_TITLE": "Binge-Watching Infinito",
"FEATURE_2_DESC": "Riproduzione automatica sincronizzata. KoalaSync rileva automaticamente i cambi di episodio e blocca la riproduzione finché tutti i partecipanti non hanno caricato il video successivo.",
"FEATURE_3_TITLE": "Zero Account / Massima Privacy",
"FEATURE_3_DESC": "Nessuna registrazione, nessun tracciamento, nessuna traccia persistente. Il server funziona interamente nella RAM effimera e cancella la tua stanza quando te ne vai.",
"WHY_SUBTITLE": "Sincronizzazione affidabile, massima privacy e configurazione immediata.",
"FEATURE_1_TITLE": "Controllo Totale / Sincro Istantanea",
"FEATURE_1_DESC": "Se uno mette in pausa, tutti si fermano. Se uno salta in avanti, gli altri lo seguono. Tutto in tempo reale.",
"FEATURE_2_TITLE": "Maratone senza interruzioni",
"FEATURE_2_DESC": "Riproduzione automatica sincronizzata. KoalaSync attende che tutti abbiano caricato l'episodio successivo prima di farlo partire.",
"FEATURE_3_TITLE": "Senza Account / Privacy Totale",
"FEATURE_3_DESC": "Niente registrazioni o tracciamenti. Il server non salva nulla e cancella la tua stanza appena te ne vai.",
"FEATURE_4_TITLE": "Supporto HTML5 Universale",
"FEATURE_4_DESC": "Funziona su YouTube, Twitch, Netflix, Jellyfin, Emby e quasi tutte le pagine web standard contenenti un elemento video HTML5. Compatibile anche con server personalizzati. Nessun dato video viene mai inviato ai nostri server — solo metadati di sincronizzazione.",
"FEATURE_5_TITLE": "Ospitabile Autonomamente e Pronto per Docker",
"FEATURE_5_DESC": "I nostri server ufficiali sono gratuiti e veloci, ma puoi gestirne uno tuo se preferisci. Avvia il tuo server di inoltro privato in pochi secondi tramite Docker.",
"FEATURE_6_TITLE": "Inviti Istantanei / Accesso con 1 Click",
"FEATURE_6_DESC": "Nessun indirizzo IP o password da scambiare. Condividi un link di invito generato con i tuoi amici per farli entrare nella stanza automaticamente con un solo clic.",
"FEATURE_7_TITLE": "Equalizzazione Volume / Compressore Audio",
"FEATURE_7_DESC": "Basta regolare continuamente il volume. Il compressore audio integrato potenzia i dialoghi sommessi e attenua le esplosioni forti. Funziona su Netflix, Prime Video, Disney+, YouTube e qualsiasi video HTML5. Completamente locale — nessun dato audio lascia il tuo computer.",
"FEATURE_8_TITLE": "Open Source (MIT License)",
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
"COMP_TITLE": "KoalaSync vs. Teleparty",
"COMP_SUBTITLE": "Scopri perché l'open source, senza pubblicità e attento alla privacy è il modo migliore per guardare i video insieme.",
"COMP_COL_FEATURE": "Funzionalità",
"COMP_FEAT_1_NAME": "Costi / Paywall",
"COMP_FEAT_1_DESC": "Abbonamenti premium, costi nascosti o funzionalità bloccate.",
"COMP_FEAT_1_KOALA": "100% Gratuito",
"COMP_FEAT_1_TELE": "Piani a Pagamento / Blocchi Premium",
"COMP_FEAT_2_NAME": "Licenza / Codice Controllabile",
"COMP_FEAT_2_DESC": "Indica se il codice sorgente è aperto e liberamente controllabile.",
"FEATURE_4_DESC": "Funziona su YouTube, Twitch, Netflix, Jellyfin, Emby e quasi ogni pagina web. Nessun dato video viene inviato ai nostri server.",
"FEATURE_5_TITLE": "Ospitabile in Proprio (Docker)",
"FEATURE_5_DESC": "I nostri server sono veloci, ma puoi gestire il tuo server privato in pochi secondi tramite Docker.",
"FEATURE_6_TITLE": "Inviti Rapidi / Un clic per entrare",
"FEATURE_6_DESC": "Niente password complicate. Condividi il link generato e i tuoi amici entreranno automaticamente con un solo clic.",
"FEATURE_7_TITLE": "Volume Bilanciato / Compressore Audio",
"FEATURE_7_DESC": "Basta regolare il volume ogni minuto. Il compressore integrato esalta le voci e attenua i rumori forti. Tutto in locale.",
"FEATURE_8_TITLE": "Open Source (Licenza MIT)",
"FEATURE_8_DESC": "Tutto il codice è pubblico su GitHub. Trasparenza totale, senza scatole nere o software proprietario.",
"COMP_TITLE": "KoalaSync vs Teleparty",
"COMP_SUBTITLE": "Scopri perché una soluzione open source e senza pubblicità è la scelta migliore per guardare video insieme.",
"COMP_COL_FEATURE": "Caratteristica",
"COMP_FEAT_1_NAME": "Costo / Paywall",
"COMP_FEAT_1_DESC": "Abbonamenti premium o funzioni a pagamento.",
"COMP_FEAT_1_KOALA": "100% Gratis",
"COMP_FEAT_1_TELE": "Livelli a pagamento / Blocchi Premium",
"COMP_FEAT_2_NAME": "Codice Trasparente",
"COMP_FEAT_2_DESC": "Indica se il codice sorgente è aperto e verificabile da chiunque.",
"COMP_FEAT_2_KOALA": "Open Source (MIT)",
"COMP_FEAT_2_TELE": "Proprietario",
"COMP_FEAT_3_NAME": "Ospitare in Proprio",
"COMP_FEAT_3_DESC": "Possibilità di distribuire il proprio server di inoltro privato.",
"COMP_FEAT_3_KOALA": "Sì (Pronto per Docker)",
"COMP_FEAT_3_NAME": "Self-Hosting",
"COMP_FEAT_3_DESC": "Possibilità di avviare il proprio server privato.",
"COMP_FEAT_3_KOALA": "Sì (Docker)",
"COMP_FEAT_3_TELE": "No",
"COMP_FEAT_4_NAME": "Privacy e Tracciamento",
"COMP_FEAT_4_DESC": "Requisiti di registrazione, cookie di tracciamento e analisi.",
"COMP_FEAT_4_KOALA": "Nessuna Persistenza (Solo RAM)",
"COMP_FEAT_4_NAME": "Privacy",
"COMP_FEAT_4_DESC": "Registrazione obbligatoria e cookie di tracciamento.",
"COMP_FEAT_4_KOALA": "Nessun dato salvato",
"COMP_FEAT_4_TELE": "Google Analytics e Cookie",
"COMP_FEAT_5_NAME": "Compatibilità dei Siti",
"COMP_FEAT_5_DESC": "Siti web supportati e compatibilità dei player.",
"COMP_FEAT_5_KOALA": "Quasi tutti i video HTML5",
"COMP_FEAT_5_TELE": "Solo siti supportati",
"COMP_FEAT_6_NAME": "Localizzazione / Traduzione",
"COMP_FEAT_6_DESC": "Lingue dell'interfaccia disponibili e supporto alle traduzioni.",
"COMP_FEAT_6_KOALA": "13 Lingue (in crescita)",
"COMP_FEAT_5_NAME": "Compatibilità Siti",
"COMP_FEAT_5_DESC": "Piattaforme supportate.",
"COMP_FEAT_5_KOALA": "Quasi ogni video HTML5",
"COMP_FEAT_5_TELE": "Solo siti selezionati",
"COMP_FEAT_6_NAME": "Traduzione",
"COMP_FEAT_6_DESC": "Lingue disponibili per l'interfaccia.",
"COMP_FEAT_6_KOALA": "15 Lingue (in crescita)",
"COMP_FEAT_6_TELE": "Solo Inglese",
"COMP_FEAT_7_NAME": "Compressore Audio / Livellamento Volume",
"COMP_FEAT_7_DESC": "Normalizzazione audio integrata per bilanciare dialoghi bassi ed effetti forti",
"COMP_FEAT_7_KOALA": "Sì (4 preset, controllo dry/wet)",
"COMP_FEAT_7_NAME": "Compressore Audio",
"COMP_FEAT_7_DESC": "Bilanciamento automatico del suono per voci più chiare.",
"COMP_FEAT_7_KOALA": "Sì (4 impostazioni)",
"COMP_FEAT_7_TELE": "No",
"COMP_FOOTNOTE_1": "Stato del confronto: Maggio 2026.",
"COMP_FOOTNOTE_2": "Dettagli sui prezzi di Teleparty Premium e reti supportate:",
"COMP_FOOTNOTE_3": "Informativa sulla privacy di Teleparty e raccolta dei dati di tracciamento:",
"COMP_FOOTNOTE_4": "Funziona sui siti web che consentono l'iniezione di script nei tag video HTML5 standard. I siti web con Content Security Policy (CSP) molto rigide, protezioni DRM o wrapper del player fortemente offuscati (come shadow DOM complessi) potrebbero limitare il controllo o l'iniezione.",
"STEPS_TITLE": "Guida Introduttiva",
"COMP_FOOTNOTE_1": "Confronto aggiornato a: Maggio 2026.",
"COMP_FOOTNOTE_2": "Dettagli sui prezzi premium di Teleparty:",
"COMP_FOOTNOTE_3": "Politiche sulla privacy di Teleparty:",
"COMP_FOOTNOTE_4": "Funziona su siti che permettono l'uso di script nei tag video HTML5. Alcuni siti con protezioni DRM molto rigide potrebbero limitare il controllo automatico.",
"STEPS_TITLE": "Inizia ora",
"STEP_1_TITLE": "Installa l'Estensione",
"STEP_1_DESC": "Aggiungi KoalaSync al tuo browser dal Chrome Web Store, da Firefox Add-ons, o scarica l'ultimo ZIP per sviluppatori da GitHub.",
"STEP_1_ILLUS_DESC": "Sincronizzatore video attento alla privacy",
"STEP_1_DESC": "Aggiungi KoalaSync al tuo browser dal Chrome Web Store o dai componenti aggiuntivi di Firefox.",
"STEP_1_ILLUS_DESC": "Sincronizzatore video privato",
"STEP_1_ILLUS_DL_CHROME": "Scarica per Chrome",
"STEP_1_ILLUS_DL_FIREFOX": "Scarica per Firefox",
"STEP_1_ILLUS_ACTIVE": "Estensione Attiva",
"STEP_1_ILLUS_READY": "Pronti a guardare insieme!",
"STEP_1_ILLUS_READY": "Pronti a guardare!",
"STEP_2_TITLE": "Crea una Stanza",
"STEP_2_DESC": "Apri il popup dell'estensione e fai clic su '+ Crea Nuova Stanza'. KoalaSync genereautomaticamente un ID Stanza e una Password sicuri, entrerà nella stanza e copierà il link di invito negli appunti.",
"STEP_2_DESC": "Apri l'estensione e clicca su '+ Crea Nuova Stanza'. Verrà generato un link di invito automaticamente.",
"STEP_2_ILLUS_ROOM": "Stanza",
"STEP_2_ILLUS_SYNC": "Sincronizza",
"STEP_2_ILLUS_SETTINGS": "Impostazioni",
"STEP_2_ILLUS_SYNC": "Sincro",
"STEP_2_ILLUS_SETTINGS": "Ajustes",
"STEP_2_ILLUS_CREATE": "+ Crea Nuova Stanza",
"STEP_2_ILLUS_MANUAL": "Connessione Manuale / Avanzata",
"STEP_2_ILLUS_COPIED": "Link di invito copiato!",
"STEP_3_TITLE": "Condividi e Sincronizza",
"STEP_3_DESC": "Invia il link di invito ai tuoi amici. Una volta entrati, seleziona la scheda del tuo video e goditi la riproduzione sincronizzata.",
"STEP_3_ILLUS_IN_SYNC": "IN SINCRONIA",
"SELF_TITLE": "Per chi Ospita Autonomamente",
"SELF_SUBTITLE": "Non ti fidi del nostro server? Mantieni la piena sovranità sui tuoi dati. Distribuisci il tuo server di inoltro privato in pochi minuti.",
"SELF_MASCOT_ALT": "Un simpatico koala seduto al laptop che distribuisce un contenitore Docker per l'hosting autonomo",
"STEP_2_ILLUS_MANUAL": "Manuale / Avanzato",
"STEP_2_ILLUS_COPIED": "Link copiato!",
"STEP_3_TITLE": "Condividi e Guarda",
"STEP_3_DESC": "Invia il link ai tuoi amici. Una volta connessi, scegli il video e goditi la riproduzione sincronizzata.",
"STEP_3_ILLUS_IN_SYNC": "SINCRONIZZATI",
"SELF_TITLE": "Per utenti esperti",
"SELF_SUBTITLE": "Preferisci gestire i tuoi dati? Avvia il tuo server privato in pochi minuti.",
"SELF_MASCOT_ALT": "Un koala che configura un server Docker",
"SELF_COPY_CODE": "Copia Codice",
"SELF_GITHUB_PACKAGES": "Visualizza tutti i tag delle immagini su GitHub Packages",
"BOTTOM_TITLE": "Non sei convinto? Guarda tu stesso.",
"BOTTOM_SUBTITLE": "KoalaSync è al 100% open source, senza pubblicità e senza tracciamento. Controlla il nostro codice su GitHub o installa direttamente l'estensione del browser.",
"BOTTOM_MASCOT_ALT": "Un simpatico koala che tiene una pagina del repository GitHub vicino alla mascotte di GitHub Octocat",
"SELF_GITHUB_PACKAGES": "Vedi tutte le versioni su GitHub",
"BOTTOM_TITLE": "Ancora dubbi?",
"BOTTOM_SUBTITLE": "KoalaSync è 100% open source, senza pubblicità o tracciamenti. Controlla il nostro codice su GitHub.",
"BOTTOM_MASCOT_ALT": "Un koala con Octocat, la mascotte di GitHub",
"FAQ_TITLE": "Domande Frequenti",
"FAQ_SUBTITLE": "Tutto quello che c'è da sapere sulla visione dei video insieme in perfetta sincronia.",
"FAQ_Q1": "Posso guardare Netflix, Emby o Jellyfin in sincronia con gli amici?",
"FAQ_A1": "! KoalaSync funziona su Netflix, YouTube, Twitch e qualsiasi sito web con un lettore video HTML5 — inclusi Emby, Jellyfin e server multimediali personalizzati. Installa l'estensione, crea una stanza, condividi il link di invito e tutti guarderanno in perfetta sincronia.",
"FAQ_Q2": "KoalaSync è un'alternativa gratuita a Teleparty?",
"FAQ_A2": "Gratuito al 100% e open source con licenza MIT. Nessun piano premium, nessuna funzionalità bloccata, nessuna pubblicità — tutto è incluso. A differenza di Teleparty, il server di inoltro di KoalaSync è solo in RAM, con zero raccolta dati o account utente richiesti.",
"FAQ_Q3": "KoalaSync funziona con Emby, Jellyfin e server multimediali ospitati in proprio?",
"FAQ_A3": "Sì. KoalaSync rileva qualsiasi elemento video HTML5 standard, il che significa che funziona perfettamente con Emby, Jellyfin, Plex e qualsiasi configurazione di streaming personalizzata. Puoi anche ospitare autonomamente il server di inoltro tramite Docker.",
"FAQ_Q4": "Quali browser e piattaforme supportano KoalaSync?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave e tutti i browser basati su Chromium. Installalo direttamente dal Chrome Web Store o da Firefox Add-ons — non è richiesta alcuna configurazione manuale.",
"FAQ_Q5": "Tutti i miei amici devono avere l'estensione installata?",
"FAQ_A5": "Sì, ogni partecipante deve avere KoalaSync installato — ma non è richiesta alcuna registrazione o creazione di account. Fai clic sul link di invito, installa l'estensione se non l'hai già fatto ed entrerai automaticamente nella stanza in pochi secondi.",
"FAQ_Q6": "I miei dati sono al sicuro? E se non mi fido del server ufficiale?",
"FAQ_A6": "Completamente al sicuro. Il server di inoltro di KoalaSync è solo in RAM — tutti i dati della sessione vengono eliminati permanentemente nel momento in cui lasci la stanza. Nessun database, nessun log, nessuna analisi, nessun account. E se preferisci il controllo totale: puoi ospitare autonomamente il tuo server di inoltro privato tramite Docker in pochi minuti. L'intera codebase è open source e controllabile su GitHub.",
"FAQ_Q7": "Posso risolvere dialoghi sommessi ed esplosioni forti quando guardo video nel browser?",
"FAQ_A7": "Sì. KoalaSync 2.2.0 include un compressore audio integrato che riduce la differenza tra le parti silenziose e quelle rumorose del tuo video. Funziona localmente tramite l'API Web Audio — nessun dato audio lascia mai il tuo computer. Apri il popup, crea una stanza, vai su Impostazioni → Audio Processing, attiva il compressore e scegli un preset. Funziona su Netflix, Prime Video, Disney+, YouTube e qualsiasi sito video HTML5.",
"HOWTO_TITLE": "Come sincronizzare i video con gli amici usando KoalaSync",
"HOWTO_DESC": "Guarda qualsiasi video in perfetta sincronia con gli amici su YouTube, Netflix, Twitch e altri. Nessuna registrazione, nessun tracciamento.",
"HOWTO_STEP_1_NAME": "Installa l'estensione del browser",
"HOWTO_STEP_1_TEXT": "Aggiungi KoalaSync al tuo browser dal Chrome Web Store o da Firefox Add-ons. Funziona su qualsiasi sito web con un elemento video.",
"FAQ_SUBTITLE": "Tutto quello che c'è da sapere per guardare video in perfetta sincronia.",
"FAQ_Q1": "Posso guardare Netflix o YouTube in sincronia?",
"FAQ_A1": "Certamente! KoalaSync funziona su Netflix, YouTube, Twitch e qualsiasi sito con video HTML5. Basta installare l'estensione, creare una stanza e condividere il link.",
"FAQ_Q2": "KoalaSync è gratuito?",
"FAQ_A2": "Sì, al 100%. È open source con licenza MIT. Non ci sono funzioni a pagamento né pubblicità. A differenza di altre app, non raccogliamo dati utente.",
"FAQ_Q3": "Funziona con Emby o Jellyfin?",
"FAQ_A3": "Sì. KoalaSync è compatibile con qualsiasi player HTML5, inclusi server multimediali come Plex, Emby e Jellyfin.",
"FAQ_Q4": "Quali browser sono supportati?",
"FAQ_A4": "Chrome, Firefox, Edge, Opera, Brave e tutti i browser basati su Chromium.",
"FAQ_Q5": "I miei amici devono installare l'estensione?",
"FAQ_A5": "Sì, ogni partecipante deve avere KoalaSync. Non serve registrarsi: basta cliccare sul link e si entra subito nella stanza.",
"FAQ_Q6": "I miei dati sono al sicuro?",
"FAQ_A6": "Assolutamente. Usiamo solo la RAM del server per inoltrare i segnali; nulla viene salvato in database o log. Quando chiudi la stanza, i dati spariscono per sempre.",
"FAQ_Q7": "Posso migliorare l'audio dei film?",
"FAQ_A7": "Sì. KoalaSync include un compressore audio che rende le voci più chiare e attenua i rumori troppo forti. Lo trovi in Impostazioni → Elaborazione Audio.",
"HOWTO_TITLE": "Come sincronizzare i video con gli amici",
"HOWTO_DESC": "Guarda video in perfetta sincronia su YouTube, Netflix e Twitch. Senza account.",
"HOWTO_STEP_1_NAME": "Installa l'estensione",
"HOWTO_STEP_1_TEXT": "Aggiungi KoalaSync al tuo browser dalle store ufficiali.",
"HOWTO_STEP_2_NAME": "Crea una stanza",
"HOWTO_STEP_2_TEXT": "Apri il popup dell'estensione e fai clic su Crea Nuova Stanza. Il link di invito viene copiato automaticamente negli appunti.",
"HOWTO_STEP_2_TEXT": "Apri l'estensione e crea una stanza. Copia il link di invito.",
"HOWTO_STEP_3_NAME": "Condividi e sincronizza",
"HOWTO_STEP_3_TEXT": "Invia il link di invito ai tuoi amici. Scegli una scheda video e goditi la riproduzione sincronizzata in tempo reale.",
"FOOTER_MIT": "Open source con licenza MIT.",
"FOOTER_RAM": "Nessun dato viene memorizzato sui nostri server. Inoltro basato esclusivamente su RAM.",
"HOWTO_STEP_3_TEXT": "Invia il link ai tuoi amici e godetevi il film insieme.",
"FOOTER_MIT": "Codice aperto con licenza MIT.",
"FOOTER_RAM": "Nessun dato salvato. Retrasmissione basata su RAM.",
"FOOTER_LEGAL": "Note Legali",
"FOOTER_PRIVACY": "Informativa sulla Privacy",
"FOOTER_PRIVACY": "Privacy",
"MOCK_01": "Stanza Attiva",
"MOCK_02": "Server Ufficiale",
"MOCK_03": "Link di Invito",
"MOCK_04": "Partecipanti nella Stanza",
"MOCK_05": "YOU",
"MOCK_06": "Playing",
"MOCK_07": "Peer",
"MOCK_08": "Lascia Stanza",
"MOCK_09": "Seleziona Video",
"MOCK_10": "Controllo Remoto",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Play",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pause",
"MOCK_04": "Partecipanti",
"MOCK_05": "TU",
"MOCK_06": "In riproduzione",
"MOCK_07": "Partecipante",
"MOCK_08": "Esci dalla stanza",
"MOCK_09": "Scegli Video",
"MOCK_10": "Telecomando",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Riproduci",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pausa",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SYNC",
"MOCK_14": "Salto dagli Altri",
"MOCK_15": "Stato Ultima Attività",
"MOCK_16": "In attesa di 1 partecipante (KoalaPC)...",
"MOCK_17": "Salta e riproduci comunque",
"MOCK_18": "Tuo Nome Utente",
"MOCK_19": "Nascondi Schede Inutili",
"MOCK_20": "Auto-Sync Prossimo Episodio",
"MOCK_21": "Copia Automatica Invito",
"MOCK_22": "Notifiche del Browser",
"MOCK_23": "Risoluzione Problemi",
"MOCK_24": "Rigenera ID Peer",
"MOCK_25": "Usa questo se riscontri errori di \"Identità Duplicata\".",
"MOCK_26": "Stato Connessione",
"MOCK_27": "Connected",
"MOCK_14": "Vai dagli altri",
"MOCK_15": "Ultima attività",
"MOCK_16": "In attesa di un amico (KoalaPC)...",
"MOCK_17": "Salta l'attesa",
"MOCK_18": "Tuo Nome",
"MOCK_19": "Nascondi schede",
"MOCK_20": "Sincro auto episodi",
"MOCK_21": "Copia auto link",
"MOCK_22": "Notifiche",
"MOCK_23": "Risoluzione problemi",
"MOCK_24": "Nuovo ID Utente",
"MOCK_25": "Risolve errori di 'Identità duplicata'.",
"MOCK_26": "Connessione",
"MOCK_27": "Connesso",
"MOCK_28": "Copia Log",
"MOCK_29": "Info Debug Video",
"MOCK_30": "Cronologia Azioni Completa",
"MOCK_31": "Log (Ultimi 50)",
"MOCK_29": "Debug Video",
"MOCK_30": "Cronologia",
"MOCK_31": "Log",
"MOCK_32": "PULISCI",
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync non è affiliato, approvato o associato a Netflix, Disney+, Amazon, YouTube, Twitch o qualsiasi altra piattaforma di streaming. Tutti i marchi registrati sono di proprietà dei rispettivi titolari.",
"FOOTER_SUPPORT": "Offrimi un caffè su Ko-Fi"
"FOOTER_DISCLAIMER": "KoalaSync non è affiliato a Netflix, YouTube o altre piattaforme. I marchi appartengono ai rispettivi proprietari.",
"FOOTER_SUPPORT": "Support KoalaSync"
}
+4 -3
View File
@@ -2,11 +2,12 @@
"LANG_CODE": "ja",
"HTML_CLASS": "lang-ja",
"CANONICAL_PATH": "ja/",
"LANG_TOGGLE_URL": "",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "ja_JP",
"META_TITLE": "KoalaSync | Netflix、YouTube、あらゆる動画を友達と同期視聴",
"META_DESCRIPTION": "Netflix、YouTube、Twitch、およびあらゆるHTML5動画を友達と完全に同期して視聴。ChromeおよびFirefox用の無料のオープンソースブラウザ拡張機能。登録不要。",
"SCHEMA_APP_DESC": "Netflix、YouTube、Twitch、およびあらゆるHTML5動画を友達と完全に同期して視聴。ChromeおよびFirefox用の無料のオープンソースブラウザ拡張機能。登録不要。",
"OG_TITLE": "KoalaSync | Netflix、Emby、Jellyfin、ほぼすべての動画を友達と同期",
"OG_DESCRIPTION": "Netflix、Emby、Jellyfin、YouTube、Twitch、ほぼすべてのHTML5動画を完全に同期して視聴。ChromeおよびFirefox用のプライバシー優先のオープンソースブラウザ拡張機能。",
"TWITTER_TITLE": "KoalaSync | Netflix、Emby、Jellyfin、ほぼすべての動画を友達と同期 – ブラウザ拡張機能",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "サポートされているサイトのみ",
"COMP_FEAT_6_NAME": "ローカライズ / 翻訳",
"COMP_FEAT_6_DESC": "利用可能なインターフェース言語と翻訳サポート。",
"COMP_FEAT_6_KOALA": "13の言語(今後も追加予定)",
"COMP_FEAT_6_KOALA": "15の言語(今後も追加予定)",
"COMP_FEAT_6_TELE": "英語のみ",
"COMP_FEAT_7_NAME": "オーディオコンプレッサー / 音量均等化",
"COMP_FEAT_7_DESC": "静かな会話と大きな効果音のバランスを取る内蔵オーディオノーマライゼーション",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSyncは、Netflix、Disney+、Amazon、YouTube、Twitch、またはその他のストリーミングプラットフォームと提携、承認、または関連付けられているものではありません。すべての商標はそれぞれの所有者に帰属します。",
"FOOTER_SUPPORT": "Ko-Fiでコーヒーをおごる"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+4 -3
View File
@@ -2,11 +2,12 @@
"LANG_CODE": "ko",
"HTML_CLASS": "lang-ko",
"CANONICAL_PATH": "ko/",
"LANG_TOGGLE_URL": "",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "ko_KR",
"META_TITLE": "KoalaSync | Netflix, YouTube 및 모든 비디오를 친구와 실시간 동기화 시청",
"META_DESCRIPTION": "Netflix, YouTube, Twitch 및 모든 HTML5 비디오를 친구들과 완벽하게 동기화하여 시청하세요. Chrome 및 Firefox용 무료 오픈 소스 브라우저 확장 프로그램. 가입 필요 없음.",
"SCHEMA_APP_DESC": "Netflix, YouTube, Twitch 및 모든 HTML5 비디오를 친구들과 완벽하게 동기화하여 시청하세요. Chrome 및 Firefox용 무료 오픈 소스 브라우저 확장 프로그램. 가입 필요 없음.",
"OG_TITLE": "KoalaSync | Netflix, Emby, Jellyfin 및 거의 모든 비디오를 친구와 동기화 시청",
"OG_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch 및 거의 모든 HTML5 비디오를 완벽하게 동기화하여 시청하세요. Chrome 및 Firefox용 개인정보 보호 중심 오픈 소스 브라우저 확장 프로그램.",
"TWITTER_TITLE": "KoalaSync | Netflix, Emby, Jellyfin 및 거의 모든 비디오를 친구와 동기화 시청 – 브라우저 확장 프로그램",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "지원되는 사이트만 가능",
"COMP_FEAT_6_NAME": "로컬라이징 / 다국어 지원",
"COMP_FEAT_6_DESC": "제공되는 인터페이스 언어 및 번역 지원 여부.",
"COMP_FEAT_6_KOALA": "13개 언어 지원 (추가 중)",
"COMP_FEAT_6_KOALA": "15개 언어 지원 (추가 중)",
"COMP_FEAT_6_TELE": "영어만 지원",
"COMP_FEAT_7_NAME": "오디오 압축기 / 볼륨 레벨링",
"COMP_FEAT_7_DESC": "조용한 대화와 큰 효과음의 균형을 맞추는 내장 오디오 노멀라이제이션",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync는 Netflix, Disney+, Amazon, YouTube, Twitch 또는 기타 스트리밍 플랫폼과 제휴, 보증 또는 연관되어 있지 않습니다. 모든 상표는 해당 소유자의 재산입니다.",
"FOOTER_SUPPORT": "Ko-Fi에서 커피 한 잔 사주세요"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+5 -4
View File
@@ -2,11 +2,12 @@
"LANG_CODE": "nl",
"HTML_CLASS": "lang-nl",
"CANONICAL_PATH": "nl/",
"LANG_TOGGLE_URL": "",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "nl_NL",
"META_TITLE": "KoalaSync | Synchroniseer Netflix, YouTube & elke video met vrienden",
"META_DESCRIPTION": "Bekijk Netflix, YouTube, Twitch en elke HTML5-video in perfecte synchronisatie met vrienden. Gratis, open-source browserextensie voor Chrome en Firefox. Geen registratie nodig.",
"SCHEMA_APP_DESC": "Bekijk Netflix, YouTube, Twitch en elke HTML5-video in perfecte synchronisatie met vrienden. Gratis, open-source browserextensie voor Chrome en Firefox. Geen registratie nodig.",
"OG_TITLE": "KoalaSync | Synchroniseer Netflix, Emby, Jellyfin & bijna elke video met vrienden",
"OG_DESCRIPTION": "Bekijk Netflix, Emby, Jellyfin, YouTube, Twitch en bijna elke HTML5-video in perfecte synchronisatie. Privacy-first, open-source browserextensie voor Chrome & Firefox.",
"TWITTER_TITLE": "KoalaSync | Synchroniseer Netflix, Emby, Jellyfin & bijna elke video met vrienden Browserextensie",
@@ -49,7 +50,7 @@
"FEATURE_7_TITLE": "Volume egaliseren / Audiocompressor",
"FEATURE_7_DESC": "Niet langer aan de volumeknop zitten. De ingebouwde audiocompressor versterkt stille dialogen en dempt harde explosies. Werkt op Netflix, Prime Video, Disney+, YouTube en elke HTML5-video. Volledig lokaal — geen audiogegevens verlaten uw computer.",
"FEATURE_8_TITLE": "Open Source (MIT License)",
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs 100% transparency from the relay server to the browser extension.",
"FEATURE_8_DESC": "De volledige codebase is openbaar controleerbaar op GitHub. Geen zwarte dozen, geen propri?taire blobs - 100% transparantie van de relayserver tot de browserextensie.",
"COMP_TITLE": "KoalaSync vs. Teleparty",
"COMP_SUBTITLE": "Ontdek waarom open-source, reclamevrij en privacy-first de superieure manier is om samen te kijken.",
"COMP_COL_FEATURE": "Functie",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "Alleen ondersteunde sites",
"COMP_FEAT_6_NAME": "Lokalisatie / Vertaling",
"COMP_FEAT_6_DESC": "Beschikbare interfacetalen en vertaalondersteuning.",
"COMP_FEAT_6_KOALA": "13 talen (en groeiend)",
"COMP_FEAT_6_KOALA": "15 talen (en groeiend)",
"COMP_FEAT_6_TELE": "Alleen Engels",
"COMP_FEAT_7_NAME": "Audiocompressor / Volume Egaliseren",
"COMP_FEAT_7_DESC": "Ingebouwde audionormalisatie om stille dialogen en luide effecten in balans te brengen",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync is niet verbonden aan, goedgekeurd door of geassocieerd met Netflix, Disney+, Amazon, YouTube, Twitch of enig ander streamingplatform. Alle handelsmerken zijn eigendom van hun respectieve eigenaren.",
"FOOTER_SUPPORT": "Trakteer op een koffie via Ko-Fi"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+4 -3
View File
@@ -2,11 +2,12 @@
"LANG_CODE": "pl",
"HTML_CLASS": "lang-pl",
"CANONICAL_PATH": "pl/",
"LANG_TOGGLE_URL": "",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "pl_PL",
"META_TITLE": "KoalaSync | Synchronizuj Netflix, YouTube i dowolne wideo ze znajomymi",
"META_DESCRIPTION": "Oglądaj Netflix, YouTube, Twitch i dowolne wideo HTML5 w idealnej synchronizacji ze znajomymi. Darmowe rozszerzenie o otwartym kodzie dla Chrome i Firefox. Bez rejestracji.",
"SCHEMA_APP_DESC": "Oglądaj Netflix, YouTube, Twitch i dowolne wideo HTML5 w idealnej synchronizacji ze znajomymi. Darmowe rozszerzenie o otwartym kodzie dla Chrome i Firefox. Bez rejestracji.",
"OG_TITLE": "KoalaSync | Synchronizuj Netflix, Emby, Jellyfin i prawie każde wideo ze znajomymi",
"OG_DESCRIPTION": "Oglądaj Netflix, Emby, Jellyfin, YouTube, Twitch i prawie każde wideo HTML5 w idealnej synchronizacji. Zorientowane na prywatność rozszerzenie o otwartym kodzie dla Chrome i Firefox.",
"TWITTER_TITLE": "KoalaSync | Synchronizuj Netflix, Emby, Jellyfin i prawie każde wideo ze znajomymi Rozszerzenie przeglądarki",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "Tylko obsługiwane strony",
"COMP_FEAT_6_NAME": "Lokalizacja / Tłumaczenie",
"COMP_FEAT_6_DESC": "Dostępne języki interfejsu i wsparcie tłumaczeń.",
"COMP_FEAT_6_KOALA": "13 języków (i rośnie)",
"COMP_FEAT_6_KOALA": "15 języków (i rośnie)",
"COMP_FEAT_6_TELE": "Tylko angielski",
"COMP_FEAT_7_NAME": "Kompresor Audio / Wyrównywanie Głośności",
"COMP_FEAT_7_DESC": "Wbudowana normalizacja dźwięku do równoważenia cichych dialogów i głośnych efektów",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync nie jest powiązany, wspierany ani stowarzyszony z Netflix, Disney+, Amazon, YouTube, Twitch ani żadną inną platformą streamingową. Wszystkie znaki towarowe są własnością ich odpowiednich właścicieli.",
"FOOTER_SUPPORT": "Postaw kawę na Ko-Fi"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+149 -148
View File
@@ -6,174 +6,175 @@
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "pt_BR",
"META_TITLE": "KoalaSync | Sincronize Netflix, YouTube e qualquer vídeo com amigos",
"META_DESCRIPTION": "Assista Netflix, YouTube, Twitch e qualquer vídeo HTML5 em perfeita sincronia com amigos. Extensão de navegador gratuita e de código aberto. Sem registro.",
"OG_TITLE": "KoalaSync | Sincronize Netflix, Emby, Jellyfin e quase qualquer vídeo com amigos",
"OG_DESCRIPTION": "Assista à Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5 em perfeita sincronia. Extensão de navegador de código aberto e focada em privacidade para Chrome e Firefox.",
"TWITTER_TITLE": "KoalaSync | Sincronize Netflix, Emby, Jellyfin e quase qualquer vídeo com amigos Extensão de navegador",
"TWITTER_DESCRIPTION": "Assista à Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5 em perfeita sincronia com amigos. Extensão de navegador de código aberto e focada em privacidade para Chrome e Firefox.",
"META_DESCRIPTION": "Assista Netflix, YouTube, Twitch e qualquer vídeo HTML5 em perfeita sincronia. Extensão de navegador gratuita e open source. Sem cadastro.",
"SCHEMA_APP_DESC": "Assista Netflix, YouTube, Twitch e qualquer vídeo HTML5 em perfeita sincronia. Extensão de navegador gratuita e open source. Sem cadastro.",
"OG_TITLE": "KoalaSync | Sincronize Netflix, Emby, Jellyfin e vídeos com amigos",
"OG_DESCRIPTION": "Assista a vídeos em perfeita sincronia com seus amigos. Extensão open source e focada em privacidade.",
"TWITTER_TITLE": "KoalaSync | Sincronize vídeos online com seus amigos",
"TWITTER_DESCRIPTION": "Assista a vídeos junto com seus amigos em sincronia perfeita. Open source e seguro.",
"NAV_FEATURES": "Recursos",
"NAV_HOW_IT_WORKS": "Como funciona",
"HERO_TITLE": "Assistam juntos.<span class='hero-line2'>Sincronia perfeita.</span>",
"HERO_SUBTITLE": "Sua noite de cinema à distância sem atrasos. Sem registro, sem coleta de dados. Apenas compartilhe o link e assistam juntos.",
"HERO_MASCOT_ALT": "Um lindo coala em pé olhando para baixo em direção aos botões de download",
"NAV_HOW_IT_WORKS": "Como Funciona",
"HERO_TITLE": "Assistam Juntos.<span class='hero-line2'>Sincronia Perfeita.</span>",
"HERO_SUBTITLE": "Sua noite de cinema à distância sem atrasos. Sem contas, sem coleta de dados. Apenas compartilhe o link e assistam juntos.",
"HERO_MASCOT_ALT": "Um coala fofo olhando para os botões de download",
"ADD_TO_CHROME": "Adicionar ao Chrome",
"ADD_TO_FIREFOX": "Adicionar ao Firefox",
"COMPAT_HEADING": "Funciona nas suas plataformas favoritas",
"COMPAT_MORE": "e muitas mais",
"COMPAT_TOOLTIP": "Funciona em quase qualquer site com um elemento de vídeo",
"USE_CASES_TITLE": "Perfeito para qualquer ocasião",
"USE_CASES_SUBTITLE": "Seja perto ou longe, o KoalaSync une as pessoas em torno de seus vídeos favoritos.",
"USE_CASE_1_ALT": "Dois coalas fofos sentados juntos e compartilhando um balde de pipoca",
"USE_CASE_1_TITLE": "Noite de cinema com amigos",
"USE_CASE_1_DESC": "Sincronize seus filmes em tempo real e converse pelo Discord, Zoom ou seu aplicativo de chamada de voz favorito. É como se estivessem na mesma sala.",
"USE_CASE_2_ALT": "Um professor coala fazendo uma apresentação em uma tela para dois alunos coala remotos",
"USE_CASE_2_TITLE": "Aprendizado remoto",
"USE_CASE_2_DESC": "Analise tutoriais online, palestras ou transmissões de treinamento de desenvolvedores junto com colegas de classe ou de trabalho. Pause e discuta etapas complexas em perfeita harmonia.",
"USE_CASE_3_ALT": "Um casal de coalas fofos assistindo juntos remotamente; um está no PC e o outro deitado com um laptop, com corações voando entre eles",
"USE_CASE_3_TITLE": "Relacionamentos à distância",
"USE_CASE_3_DESC": "Aproxime a distância e aproveite encontros virtuais. Experimente cada reviravolta, ria das mesmas piadas e compartilhe momentos emocionante no mesmo milissegundo.",
"COMPAT_MORE": "e muitas outras",
"COMPAT_TOOLTIP": "Funciona em quase qualquer site com player de vídeo HTML5",
"USE_CASES_TITLE": "Perfeito para Qualquer Ocasião",
"USE_CASES_SUBTITLE": "Seja de perto ou de longe, o KoalaSync une as pessoas através dos vídeos.",
"USE_CASE_1_ALT": "Dois coalas compartilhando pipoca",
"USE_CASE_1_TITLE": "Noite de Cinema com Amigos",
"USE_CASE_1_DESC": "Sincronize seus filmes em tempo real e converse pelo Discord ou Zoom. É como se estivessem na mesma sala.",
"USE_CASE_2_ALT": "Um professor coala dando aula online",
"USE_CASE_2_TITLE": "Aprendizado Remoto",
"USE_CASE_2_DESC": "Analise tutoriais ou aulas online com seus colegas. Pause e discuta os tópicos em harmonia.",
"USE_CASE_3_ALT": "Um casal de coalas assistindo a um vídeo à distância",
"USE_CASE_3_TITLE": "Relacionamentos à Distância",
"USE_CASE_3_DESC": "Aproveite as noites a dois. Ria das mesmas piadas e sinta as mesmas emoções exatamente no mesmo segundo.",
"WHY_TITLE": "Por que o KoalaSync?",
"WHY_SUBTITLE": "Desenvolvido para uma sincronização confiável, privacidade em primeiro lugar e configuração simples.",
"FEATURE_1_TITLE": "Controle total / Sincronia instantânea",
"FEATURE_1_DESC": "Um pausa, todos pausam. Um avança, todos seguem. Nosso protocolo de sincronização em duas fases coordena a reprodução em tempo real entre todos os participantes.",
"FEATURE_2_TITLE": "Maratones sem fim",
"FEATURE_2_DESC": "Reprodução automática sincronizada. O KoalaSync detecta automaticamente transições de episódios e pausa a reprodução até que todos os participantes tenham carregado o próximo vídeo.",
"FEATURE_3_TITLE": "Sem contas / Privacidade absoluta",
"FEATURE_3_DESC": "Sem registro, sem rastreamento, sem armazenamento de dados. O servidor roda inteiramente em memória RAM efêmera e limpa sua sala assim que você sai.",
"FEATURE_4_TITLE": "Suporte universal a HTML5",
"FEATURE_4_DESC": "Funciona no YouTube, Twitch, Netflix, Jellyfin, Emby e em quase qualquer página web padrão com um elemento de vídeo HTML5. Também é compatível com servidores próprios. Nenhum dado de vídeo é enviado aos nossos servidores — apenas metadados de sincronização.",
"FEATURE_5_TITLE": "Auto-hospedável e pronto para Docker",
"FEATURE_5_DESC": "Nossos servidores oficiais são rápidos e gratuitos, mas você pode ter controle total se desejar. Inicie seu próprio servidor de retransmissão privado em segundos via Docker.",
"FEATURE_6_TITLE": "Convites instantâneos / Entrada em 1 clique",
"FEATURE_6_DESC": "Sem troca de endereços IP ou senhas. Compartilhe um link de convite gerado para que seus amigos entrem automaticamente na sua sala com um único clique.",
"FEATURE_7_TITLE": "Nivelamento de volume / Compressor de áudio",
"FEATURE_7_DESC": "Chega de viver ajustando o volume. O compressor de áudio integrado realça diálogos baixos e controla explosões altas. Funciona na Netflix, Prime Video, Disney+, YouTube e qualquer vídeo HTML5. Completamente local — nenhum dado de áudio sai do seu computador.",
"FEATURE_8_TITLE": "Open Source (MIT License)",
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
"WHY_SUBTITLE": "Sincronização confiável, máxima privacidade e uso simples.",
"FEATURE_1_TITLE": "Controle Total / Sincronia Instantânea",
"FEATURE_1_DESC": "Se um pausa, todos pausam. Se um avança, todos seguem. Sincronização em tempo real para todos os participantes.",
"FEATURE_2_TITLE": "Maratonas Sem Interrupções",
"FEATURE_2_DESC": "O KoalaSync detecta a mudança de episódios e pausa automaticamente até que todos tenham carregado o vídeo seguinte.",
"FEATURE_3_TITLE": "Sem Contas / Privacidade Absoluta",
"FEATURE_3_DESC": "Sem cadastros ou rastreio. O servidor roda apenas na RAM e apaga sua sala assim que você sai.",
"FEATURE_4_TITLE": "Suporte HTML5 Universal",
"FEATURE_4_DESC": "Funciona no YouTube, Twitch, Netflix, Jellyfin, Emby e em qualquer site web. Nenhum dado de vídeo é enviado a nós.",
"FEATURE_5_TITLE": "Servidores Próprios (Docker)",
"FEATURE_5_DESC": "Nossos servidores são gratuitos, mas você pode assumir o controle total e iniciar seu próprio servidor privado em segundos com Docker.",
"FEATURE_6_TITLE": "Convites Rápidos / Entrar com 1 Clique",
"FEATURE_6_DESC": "Chega de senhas complicadas. Compartilhe o link e seus amigos entram na sala automaticamente.",
"FEATURE_7_TITLE": "Compressor de Áudio Integrado",
"FEATURE_7_DESC": "O compressor de áudio reforça diálogos baixos e diminui o volume de explosões. Funciona localmente, nenhum áudio sai do seu computador.",
"FEATURE_8_TITLE": "Open Source (Licença MIT)",
"FEATURE_8_DESC": "Todo o código é público no GitHub. 100% de transparência, sem softwares ocultos.",
"COMP_TITLE": "KoalaSync vs Teleparty",
"COMP_SUBTITLE": "Veja por que o código aberto, sem anúncios e com privacidade em primeiro lugar é a melhor escolha para assistirem juntos.",
"COMP_SUBTITLE": "Descubra por que a nossa alternativa aberta e sem anúncios é melhor.",
"COMP_COL_FEATURE": "Recurso",
"COMP_FEAT_1_NAME": "Custo / Bloqueios de recursos",
"COMP_FEAT_1_DESC": "Assinaturas premium, taxas ocultas ou recursos bloqueados.",
"COMP_FEAT_1_NAME": "Custos / Paywalls",
"COMP_FEAT_1_DESC": "Assinaturas premium ou recursos bloqueados.",
"COMP_FEAT_1_KOALA": "100% Gratuito",
"COMP_FEAT_1_TELE": "Assinaturas pagas / Bloqueios Premium",
"COMP_FEAT_2_NAME": "Licença / Código auditável",
"COMP_FEAT_2_DESC": "Se o código-fonte é aberto e de livre auditoria.",
"COMP_FEAT_1_TELE": "Assinaturas Pagas / Bloqueios Premium",
"COMP_FEAT_2_NAME": "Código Aberto",
"COMP_FEAT_2_DESC": "Transparência do código-fonte.",
"COMP_FEAT_2_KOALA": "Código Aberto (MIT)",
"COMP_FEAT_2_TELE": "Proprietário",
"COMP_FEAT_3_NAME": "Auto-hospedagem",
"COMP_FEAT_3_DESC": "Capacidade de implantar seu próprio servidor de retransmissão privado.",
"COMP_FEAT_3_KOALA": "Sim (pronto para Docker)",
"COMP_FEAT_3_NAME": "Self-Hosting",
"COMP_FEAT_3_DESC": "Possibilidade de usar o seu próprio servidor.",
"COMP_FEAT_3_KOALA": "Sim (Docker)",
"COMP_FEAT_3_TELE": "Não",
"COMP_FEAT_4_NAME": "Privacidade e Rastreamento",
"COMP_FEAT_4_DESC": "Exigências de registro, cookies de rastreamento e análises.",
"COMP_FEAT_4_KOALA": "Sem persistência (apenas RAM)",
"COMP_FEAT_4_DESC": "Coleta de dados e cookies.",
"COMP_FEAT_4_KOALA": "Nenhum dado salvo",
"COMP_FEAT_4_TELE": "Google Analytics e Cookies",
"COMP_FEAT_5_NAME": "Compatibilidade de sites",
"COMP_FEAT_5_DESC": "Sites suportados e compatibilidade do player.",
"COMP_FEAT_5_NAME": "Compatibilidade",
"COMP_FEAT_5_DESC": "Sites de vídeo suportados.",
"COMP_FEAT_5_KOALA": "Quase qualquer vídeo HTML5",
"COMP_FEAT_5_TELE": "Apenas sites suportados",
"COMP_FEAT_6_NAME": "Localização / Tradução",
"COMP_FEAT_6_DESC": "Idiomas de interface disponíveis e suporte a tradução.",
"COMP_FEAT_6_KOALA": "13 idiomas (e crescendo)",
"COMP_FEAT_6_TELE": "Apenas inglês",
"COMP_FEAT_7_NAME": "Compressor de Áudio / Nivelamento de Volume",
"COMP_FEAT_7_DESC": "Normalização de áudio integrada para equilibrar diálogos baixos e efeitos altos",
"COMP_FEAT_7_KOALA": "Sim (4 predefinições, controle dry/wet)",
"COMP_FEAT_5_TELE": "Apenas sites selecionados",
"COMP_FEAT_6_NAME": "Traduzido",
"COMP_FEAT_6_DESC": "Idiomas suportados.",
"COMP_FEAT_6_KOALA": "15 Idiomas",
"COMP_FEAT_6_TELE": "Apenas Inglês",
"COMP_FEAT_7_NAME": "Compressor de Áudio",
"COMP_FEAT_7_DESC": "Melhora a clareza das vozes nos vídeos.",
"COMP_FEAT_7_KOALA": "Sim (4 configurações)",
"COMP_FEAT_7_TELE": "Não",
"COMP_FOOTNOTE_1": "Estado da comparação: maio de 2026.",
"COMP_FOOTNOTE_2": "Preços oficiais do Teleparty Premium e detalhes das redes suportadas:",
"COMP_FOOTNOTE_3": "Políticas de privacidade do Teleparty e coleta de dados de rastreamento:",
"COMP_FOOTNOTE_4": "Funciona em sites que permitem injeções de scripts em tags de vídeo HTML5 padrão. Sites com políticas de segurança de conteúdo (CSP) muito rígidas, proteção contra cópia DRM ou contêineres de player fortemente ocultos (como DOMs de sombra complexos) podem limitar o controle automatizado ou a injeção.",
"STEPS_TITLE": "Como começar",
"STEP_1_TITLE": "Instalar a extensão",
"STEP_1_DESC": "Adicione o KoalaSync ao seu navegador a partir da Chrome Web Store, Firefox Add-ons ou baixe o arquivo ZIP de desenvolvedor mais recente do GitHub.",
"STEP_1_ILLUS_DESC": "Sincronizador de vídeo focado em privacidade",
"STEP_1_ILLUS_DL_CHROME": "Baixar para o Chrome",
"STEP_1_ILLUS_DL_FIREFOX": "Baixar para o Firefox",
"STEP_1_ILLUS_ACTIVE": "Extensão ativa",
"STEP_1_ILLUS_READY": "Prontos para assistir juntos!",
"STEP_2_TITLE": "Criar uma sala",
"STEP_2_DESC": "Abra a janela da extensão e clique em '+ Criar nova sala'. O KoalaSync gera automaticamente um ID de sala e senha seguros, entra nela e copia o link de convite para a sua área de transferência.",
"COMP_FOOTNOTE_1": "Comparação atualizada em: Maio de 2026.",
"COMP_FOOTNOTE_2": "Preços oficiais do Teleparty:",
"COMP_FOOTNOTE_3": "Políticas de privacidade do Teleparty:",
"COMP_FOOTNOTE_4": "Funciona em sites que permitem injeção de scripts. Plataformas com DRM estrito podem ter limitações.",
"STEPS_TITLE": "Como Começar",
"STEP_1_TITLE": "Instalar a Extensão",
"STEP_1_DESC": "Adicione o KoalaSync ao seu navegador (Chrome ou Firefox).",
"STEP_1_ILLUS_DESC": "Extensão segura para ver vídeos",
"STEP_1_ILLUS_DL_CHROME": "Baixar para Chrome",
"STEP_1_ILLUS_DL_FIREFOX": "Baixar para Firefox",
"STEP_1_ILLUS_ACTIVE": "Extensão Ativa",
"STEP_1_ILLUS_READY": "Pronto!",
"STEP_2_TITLE": "Criar uma Sala",
"STEP_2_DESC": "Abra a extensão e clique em '+ Criar nova sala'. O link de convite é copiado automaticamente.",
"STEP_2_ILLUS_ROOM": "Sala",
"STEP_2_ILLUS_SYNC": "Sincro",
"STEP_2_ILLUS_SETTINGS": "Opções",
"STEP_2_ILLUS_SYNC": "Sincronia",
"STEP_2_ILLUS_SETTINGS": "Configurações",
"STEP_2_ILLUS_CREATE": "+ Criar nova sala",
"STEP_2_ILLUS_MANUAL": "Conexão manual / Avançado",
"STEP_2_ILLUS_COPIED": "Link de convite copiado!",
"STEP_3_TITLE": "Compartilhar e Sincronizar",
"STEP_3_DESC": "Envie o link de convite para seus amigos. Assim que eles entrarem, selecione a aba do seu vídeo e aproveite a reprodução sincronizada.",
"STEP_3_ILLUS_IN_SYNC": "SINCRONIZADO",
"SELF_TITLE": "Para quem hospeda próprio",
"SELF_SUBTITLE": "Não confia no nosso servidor? Mantenha a soberania total dos seus dados. Implante seu próprio servidor de retransmissão em minutos.",
"SELF_MASCOT_ALT": "Um coala fofo sentado à frente de um laptop implantando um contêiner Docker para hospedagem própria",
"SELF_COPY_CODE": "Copiar código",
"SELF_GITHUB_PACKAGES": "Ver todas as tags de imagens no GitHub Packages",
"BOTTOM_TITLE": "Ainda não se convenceu? Veja por si mesmo.",
"BOTTOM_SUBTITLE": "O KoalaSync é 100% de código aberto, livre de anúncios e sem rastreamento. Audite nosso código no GitHub ou instale a extensão do navegador diretamente.",
"BOTTOM_MASCOT_ALT": "Um coala fofo segurando uma página de repositório do GitHub ao lado de Octocat, a mascotte do GitHub",
"FAQ_TITLE": "Perguntas frequentes",
"FAQ_SUBTITLE": "Tudo o que você precisa saber para assistir vídeos juntos em perfeita sincronia.",
"FAQ_Q1": "Posso assistir Netflix, Emby ou Jellyfin sincronizado com amigos?",
"FAQ_A1": "Sim! O KoalaSync funciona com Netflix, YouTube, Twitch e qualquer site com player de vídeo HTML5 — incluindo Emby, Jellyfin e servidores de mídia próprios. Instale a extensão, crie uma sala, compartilhe o link e todos assistem em sincronia perfeita.",
"FAQ_Q2": "O KoalaSync é uma alternativa gratuita ao Teleparty?",
"FAQ_A2": "100% gratuito e open source sob a licença MIT. Sem níveis premium, sem recursos bloqueados, sem anúncios. Diferente do Teleparty, o servidor relay do KoalaSync opera apenas em RAM — sem coleta de dados ou contas de usuário.",
"FAQ_Q3": "O KoalaSync funciona com Emby, Jellyfin e servidores de mídia próprios?",
"FAQ_A3": "Sim. O KoalaSync detecta qualquer elemento de vídeo HTML5, funcionando perfeitamente com Emby, Jellyfin, Plex e qualquer configuração de streaming personalizada. Você também pode hospedar seu próprio servidor relay via Docker.",
"FAQ_Q4": "Quais navegadores e plataformas suportam o KoalaSync?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave e todos os navegadores baseados em Chromium. Instale diretamente da Chrome Web Store ou das extensões do Firefox.",
"FAQ_Q5": "Todos os meus amigos precisam instalar a extensão?",
"FAQ_A5": "Sim, cada participante precisa do KoalaSync — mas sem cadastro ou criação de conta. Basta clicar no link de convite, instalar a extensão se necessário e você entra na sala automaticamente.",
"FAQ_Q6": "Meus dados estão seguros? E se eu não confiar no servidor oficial?",
"FAQ_A6": "Totalmente seguro. O servidor relay do KoalaSync opera apenas em RAM — todos os dados da sessão são excluídos ao sair da sala. Sem banco de dados, sem registros, sem análises, sem contas. Se preferir controle total: hospede seu próprio servidor relay privado via Docker em minutos. O código fonte é totalmente aberto e auditável no GitHub.",
"FAQ_Q7": "Posso corrigir diálogos baixos e explosões altas ao assistir vídeos no navegador?",
"FAQ_A7": "Sim. O KoalaSync 2.2.0 inclui um compressor de áudio integrado que reduz a diferença entre partes silenciosas e barulhentas do seu vídeo. Funciona localmente via Web Audio API — nenhum dado de áudio sai do seu computador. Abra o popup, crie uma sala, vá em Configurações → Audio Processing, ative o compressor e escolha um preset. Funciona na Netflix, Prime Video, Disney+, YouTube e qualquer site de vídeo HTML5.",
"HOWTO_TITLE": "Como sincronizar vídeos com amigos usando o KoalaSync",
"HOWTO_DESC": "Assista vídeos em perfeita sincronia com amigos no YouTube, Netflix, Twitch e mais. Sem cadastro, sem rastreamento.",
"HOWTO_STEP_1_NAME": "Instalar a extensão",
"HOWTO_STEP_1_TEXT": "Adicione o KoalaSync ao seu navegador pela Chrome Web Store ou extensões do Firefox. Funciona em qualquer site com elemento de vídeo.",
"HOWTO_STEP_2_NAME": "Criar uma sala",
"HOWTO_STEP_2_TEXT": "Abra a extensão e clique em Criar nova sala. O link de convite é copiado automaticamente para sua área de transferência.",
"HOWTO_STEP_3_NAME": "Compartilhar e sincronizar",
"HOWTO_STEP_3_TEXT": "Envie o link de convite para seus amigos. Selecione uma aba de vídeo e aproveite a reprodução sincronizada em tempo real.",
"STEP_2_ILLUS_MANUAL": "Manual / Avançado",
"STEP_2_ILLUS_COPIED": "Link copiado!",
"STEP_3_TITLE": "Compartilhar e Assistir",
"STEP_3_DESC": "Envie o link aos amigos. Escolha um vídeo e aproveitem juntos.",
"STEP_3_ILLUS_IN_SYNC": "SINCRONIZADOS",
"SELF_TITLE": "Para usuários avançados",
"SELF_SUBTITLE": "Prefere gerenciar seus próprios dados? Inicie seu próprio servidor em minutos com Docker.",
"SELF_MASCOT_ALT": "Um coala configurando um servidor Docker",
"SELF_COPY_CODE": "Copiar Código",
"SELF_GITHUB_PACKAGES": "Ver pacotes no GitHub",
"BOTTOM_TITLE": "Ainda tem dúvidas?",
"BOTTOM_SUBTITLE": "O KoalaSync é 100% open source, sem anúncios e sem rastreamento. Verifique nosso código no GitHub.",
"BOTTOM_MASCOT_ALT": "Um coala junto do Octocat, mascote do GitHub",
"FAQ_TITLE": "Perguntas Frequentes",
"FAQ_SUBTITLE": "Tudo o que você precisa saber.",
"FAQ_Q1": "Posso assistir Netflix ou YouTube em sincronia com meus amigos?",
"FAQ_A1": "Sim! O KoalaSync funciona no Netflix, YouTube, Twitch e qualquer site com vídeo HTML5. Instale a extensão, compartilhe o link e pronto.",
"FAQ_Q2": "É gratuito?",
"FAQ_A2": "Sim, é 100% gratuito e open source. Sem recursos pagos e sem anúncios.",
"FAQ_Q3": "Funciona com Emby ou Jellyfin?",
"FAQ_A3": "Sim. Funciona perfeitamente em servidores multimídia próprios como Emby, Jellyfin e Plex.",
"FAQ_Q4": "Quais navegadores ele suporta?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Edge, Opera, Brave e outros baseados no Chromium.",
"FAQ_Q5": "Meus amigos precisam instalar a extensão?",
"FAQ_A5": "Sim, cada pessoa precisa da extensão, mas não é preciso criar conta. Basta um clique no link de convite.",
"FAQ_Q6": "Meus dados estão seguros?",
"FAQ_A6": "Sim. Nosso servidor usa apenas RAM. Não guardamos nada. Os dados são apagados quando a sala fecha.",
"FAQ_Q7": "Posso normalizar o áudio para ouvir melhor os diálogos?",
"FAQ_A7": "Sim! O KoalaSync possui um compressor de áudio que regula as vozes baixas e diminui as explosões. Ative-o nas Configurações de Processamento de Áudio.",
"HOWTO_TITLE": "Como usar o KoalaSync",
"HOWTO_DESC": "Sincronize vídeos no YouTube, Netflix e Twitch sem precisar de conta.",
"HOWTO_STEP_1_NAME": "Instale a extensão",
"HOWTO_STEP_1_TEXT": "Adicione o KoalaSync ao Chrome ou Firefox.",
"HOWTO_STEP_2_NAME": "Crie uma sala",
"HOWTO_STEP_2_TEXT": "Abra a extensão e crie uma sala. O link será copiado.",
"HOWTO_STEP_3_NAME": "Sincronize",
"HOWTO_STEP_3_TEXT": "Envie o link para os seus amigos e assistam o vídeo simultaneamente.",
"FOOTER_MIT": "Código aberto sob a Licença MIT.",
"FOOTER_RAM": "Nenhum dado é armazenado em nossos servidores. Retransmissão apenas em RAM.",
"FOOTER_LEGAL": "Legal Notice",
"FOOTER_PRIVACY": "Privacy Policy",
"MOCK_01": "Sala ativa",
"MOCK_02": "Servidor oficial",
"MOCK_03": "Link de convite",
"MOCK_04": "Participantes",
"FOOTER_RAM": "Sem dados salvos. Baseado em RAM.",
"FOOTER_LEGAL": "Aviso Legal",
"FOOTER_PRIVACY": "Privacidade",
"MOCK_01": "Sala Ativa",
"MOCK_02": "Servidor Oficial",
"MOCK_03": "Link de Convite",
"MOCK_04": "Participantes na Sala",
"MOCK_05": "VOCÊ",
"MOCK_06": "Reproduzindo",
"MOCK_07": "Parceiro",
"MOCK_08": "Sair da sala",
"MOCK_09": "Selecionar vídeo",
"MOCK_10": "Controle remoto",
"MOCK_11": "Reproduzir",
"MOCK_12": "Pausar",
"MOCK_13": "Pular para outros",
"MOCK_14": "Última atividade",
"MOCK_15": "Aguardando 1 parceiro (KoalaPC)...",
"MOCK_16": "Pular e reproduzir",
"MOCK_17": "Seu nome",
"MOCK_18": "Ocultar abas confusas",
"MOCK_19": "Auto-sync próximo episódio",
"MOCK_20": "Auto-copiar convite",
"MOCK_21": "Notificações",
"MOCK_22": "Solução de problemas",
"MOCK_23": "Regenerar Peer ID",
"MOCK_24": "Use se aparecer erro \"Identidade duplicada\".",
"MOCK_25": "Status da conexão",
"MOCK_26": "Conectado",
"MOCK_27": "Copiar logs",
"MOCK_28": "Info de depuração de vídeo",
"MOCK_29": "Histórico completo",
"MOCK_30": "Logs (últimos 50)",
"MOCK_31": "LIMPAR",
"MOCK_32": "CLEAR",
"MOCK_07": "Participante",
"MOCK_08": "Sair da Sala",
"MOCK_09": "Selecionar Vídeo",
"MOCK_10": "Controle Remoto",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Play",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pausa",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SINCRO",
"MOCK_14": "Ir para a posição dos outros",
"MOCK_15": "Última Atividade",
"MOCK_16": "Aguardando 1 amigo (KoalaPC)...",
"MOCK_17": "Pular e reproduzir agora",
"MOCK_18": "Seu Nome",
"MOCK_19": "Ocultar abas sem vídeo",
"MOCK_20": "Sincro auto do próximo episódio",
"MOCK_21": "Copiar Convite Auto",
"MOCK_22": "Notificações",
"MOCK_23": "Solução de Problemas",
"MOCK_24": "Regerar ID",
"MOCK_25": "Corrige erros de 'Identidade Duplicada'.",
"MOCK_26": "Status",
"MOCK_27": "Conectado",
"MOCK_28": "Copiar Logs",
"MOCK_29": "Debug de Vídeo",
"MOCK_30": "Histórico Completo",
"MOCK_31": "Logs",
"MOCK_32": "LIMPAR",
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync não é afiliado, endossado ou associado à Netflix, Disney+, Amazon, YouTube, Twitch ou qualquer outra plataforma de streaming. Todas as marcas registradas são propriedade de seus respectivos titulares.",
"FOOTER_SUPPORT": "Me pague um café no Ko-Fi"
"FOOTER_DISCLAIMER": "KoalaSync não é afiliado à Netflix, Disney+, YouTube, Twitch, etc. Todas as marcas registradas são de propriedade de seus respectivos donos.",
"FOOTER_SUPPORT": "Support KoalaSync"
}
+123 -122
View File
@@ -2,178 +2,179 @@
"LANG_CODE": "pt",
"HTML_CLASS": "lang-pt",
"CANONICAL_PATH": "pt/",
"LANG_TOGGLE_URL": "",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "pt_PT",
"META_TITLE": "KoalaSync | Sincronize Netflix, YouTube e qualquer vídeo com os amigos",
"META_DESCRIPTION": "Veja Netflix, YouTube, Twitch e qualquer vídeo HTML5 em sincronia perfeita com os amigos. Extensão de navegador gratuita e de código aberto para Chrome e Firefox. Sem registo necessário.",
"OG_TITLE": "KoalaSync | Sincronize Netflix, Emby, Jellyfin e quase qualquer vídeo com os amigos",
"OG_DESCRIPTION": "Veja Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5 em sincronia perfeita. Extensão de navegador de código aberto e focada na privacidade para Chrome e Firefox.",
"TWITTER_TITLE": "KoalaSync | Sincronize Netflix, Emby, Jellyfin e quase qualquer vídeo com os amigos Extensão de Navegador",
"TWITTER_DESCRIPTION": "Veja Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5 em sincronia perfeita com os amigos. Extensão de navegador de código aberto e focada na privacidade para Chrome e Firefox.",
"META_TITLE": "KoalaSync | Sincronize Netflix, YouTube e vídeos com os amigos",
"META_DESCRIPTION": "Veja Netflix, YouTube, Twitch e qualquer vídeo HTML5 em sincronia perfeita. Extensão gratuita e de código aberto. Sem registo.",
"SCHEMA_APP_DESC": "Veja Netflix, YouTube, Twitch e qualquer vídeo HTML5 em sincronia perfeita. Extensão gratuita e de código aberto. Sem registo.",
"OG_TITLE": "KoalaSync | Sincronize Netflix, Emby, Jellyfin e vídeos com os amigos",
"OG_DESCRIPTION": "Veja vídeos em sincronia perfeita com os amigos. Extensão de código aberto e focada na privacidade.",
"TWITTER_TITLE": "KoalaSync | Sincronize vídeos em streaming com os amigos",
"TWITTER_DESCRIPTION": "Veja vídeos com os amigos em sincronia perfeita. Extensão de código aberto focada na privacidade.",
"NAV_FEATURES": "Funcionalidades",
"NAV_HOW_IT_WORKS": "Como Funciona",
"HERO_TITLE": "Vejam Juntos.<span class='hero-line2'>Sincronizem Perfeitamente.</span>",
"HERO_SUBTITLE": "A sua noite de cinema à distância e sem atrasos. Sem registo, sem recolha de dados. Partilhe apenas um link e vejam juntos.",
"HERO_MASCOT_ALT": "Um coala fofo em pé e a olhar para baixo para os botões de download",
"HERO_TITLE": "Vejam Juntos.<span class='hero-line2'>Sincronia Perfeita.</span>",
"HERO_SUBTITLE": "A sua noite de cinema à distância e sem atrasos. Sem contas, sem recolha de dados. Partilhe um link e vejam juntos.",
"HERO_MASCOT_ALT": "Um coala fofo a olhar para os botões de download",
"ADD_TO_CHROME": "Adicionar ao Chrome",
"ADD_TO_FIREFOX": "Adicionar ao Firefox",
"COMPAT_HEADING": "Funciona nas suas plataformas favoritas",
"COMPAT_MORE": "e muitos mais",
"COMPAT_TOOLTIP": "Funciona em quase todos os sites com um elemento de vídeo",
"COMPAT_MORE": "e muitas mais",
"COMPAT_TOOLTIP": "Funciona em quase todos os sites com um leitor de vídeo HTML5",
"USE_CASES_TITLE": "Perfeito para Qualquer Ocasião",
"USE_CASES_SUBTITLE": "Perto ou longe, o KoalaSync junta as pessoas em redor dos seus vídeos favoritos.",
"USE_CASE_1_ALT": "Dois coalas fofos sentados juntos e a partilhar um balde de pipocas",
"USE_CASES_SUBTITLE": "Perto ou longe, o KoalaSync une as pessoas em redor dos seus vídeos favoritos.",
"USE_CASE_1_ALT": "Dois coalas a partilhar pipocas",
"USE_CASE_1_TITLE": "Noite de Cinema com Amigos",
"USE_CASE_1_DESC": "Sincronize os seus filmes em tempo real e fale via Discord, Zoom ou a sua app de chamada de voz favorita. Parece que estão na mesma sala.",
"USE_CASE_2_ALT": "Um professor coala a dar uma apresentação num ecrã para dois estudantes coalas à distância",
"USE_CASE_1_DESC": "Sincronize os seus filmes em tempo real e fale via Discord ou Zoom. Parece que estão na mesma sala.",
"USE_CASE_2_ALT": "Um professor coala a dar uma aula online",
"USE_CASE_2_TITLE": "Aprendizagem à Distância",
"USE_CASE_2_DESC": "Analise tutoriais online, palestras ou streams de formação com colegas. Pausa e discuta passos complexos em perfeita harmonia.",
"USE_CASE_3_ALT": "Um casal fofo de coalas a ver juntos à distância; um está sentado no PC e o outro está na cama com um portátil, com corações a voar entre eles",
"USE_CASE_2_DESC": "Analise tutoriais ou aulas online com colegas. Pause e discuta passos complexos em harmonia.",
"USE_CASE_3_ALT": "Um casal de coalas a ver vídeos à distância",
"USE_CASE_3_TITLE": "Relacionamentos à Distância",
"USE_CASE_3_DESC": "Ponte para a distância e aproveite as noites de encontros. Experimente cada reviravolta, ria das mesmas piadas e partilhe momentos emocionantes exatamente no mesmo milissegundo.",
"WHY_TITLE": "Porquê KoalaSync?",
"WHY_SUBTITLE": "Construído para uma sincronização fiável, design focado na privacidade e configuração simples.",
"FEATURE_1_TITLE": "Controlo Total / Sincronização Instantânea",
"FEATURE_1_DESC": "Um pausa, todos pausam. Um avança, todos seguem. O nosso protocolo de sincronização a duas fases coordena a reprodução em tempo real em todos os participantes.",
"FEATURE_2_TITLE": "Binge-Watching Infinito",
"FEATURE_2_DESC": "Reprodução automática em sincronia. O KoalaSync deteta automaticamente as transições de episódios e aguarda até que todos os peers tenham carregado o vídeo seguinte.",
"FEATURE_3_TITLE": "Zero Contas / Privacidade Pura",
"FEATURE_3_DESC": "Sem registo, sem rastreio, sem pegadas de persistência. O servidor corre inteiramente em RAM e purga a sala quando você sai.",
"USE_CASE_3_DESC": "Aproveite as noites a dois. Ria das mesmas piadas e sinta as mesmas emoções exatamente no mesmo milissegundo.",
"WHY_TITLE": "Porquê o KoalaSync?",
"WHY_SUBTITLE": "Sincronização fiável, máxima privacidade e uso simples.",
"FEATURE_1_TITLE": "Controlo Total / Sincronia Instantânea",
"FEATURE_1_DESC": "Se um pausa, todos pausam. Se um avança, todos seguem. Sincronização em tempo real para todos os participantes.",
"FEATURE_2_TITLE": "Maratonas sem interrupções",
"FEATURE_2_DESC": "O KoalaSync deteta a mudança de episódios e pausa automaticamente até que todos tenham carregado o vídeo seguinte.",
"FEATURE_3_TITLE": "Sem Contas / Privacidade Absoluta",
"FEATURE_3_DESC": "Sem registos ou rastreios. O servidor corre apenas na RAM e apaga a sua sala assim que sair.",
"FEATURE_4_TITLE": "Suporte HTML5 Universal",
"FEATURE_4_DESC": "Funciona no YouTube, Twitch, Netflix, Jellyfin, Emby e em quase todas as páginas web padrão que contenham um elemento de vídeo HTML5. Compatível também com servidores personalizados. Nenhum dado de vídeo é enviado aos nossos servidores — apenas metadados de sincronização.",
"FEATURE_5_TITLE": "Auto-Hospedável e Pronto para Docker",
"FEATURE_5_DESC": "Os nossos servidores oficiais são gratuitos e rápidos, mas pode assumir o controlo total se quiser. Lance o seu próprio servidor de retransmissão privado em segundos via Docker.",
"FEATURE_6_TITLE": "Convites Instantâneos / Entrada com 1 Clique",
"FEATURE_6_DESC": "Nenhum endereço IP ou palavra-passe para trocar. Partilhe um link de convite gerado com os seus amigos para que possam entrar na sala automaticamente com um único clique.",
"FEATURE_7_TITLE": "Nivelamento de Volume / Compressor de Áudio",
"FEATURE_7_DESC": "Nunca mais esteja sempre a ajustar o volume. O compressor de áudio integrado reforça diálogos suaves e doma explosões altas. Funciona na Netflix, Prime Video, Disney+, YouTube e qualquer vídeo HTML5. Totalmente local — nenhum dado de áudio sai do seu computador.",
"FEATURE_8_TITLE": "Open Source (MIT License)",
"FEATURE_8_DESC": "The entire codebase is publicly auditable on GitHub. No black boxes, no proprietary blobs — 100% transparency from the relay server to the browser extension.",
"COMP_TITLE": "KoalaSync vs. Teleparty",
"COMP_SUBTITLE": "Descubra porquê o código aberto, sem anúncios e focado na privacidade é a forma superior de ver vídeos juntos.",
"FEATURE_4_DESC": "Funciona no YouTube, Twitch, Netflix, Jellyfin, Emby e qualquer site web. Nenhum dado de vídeo é enviado para nós.",
"FEATURE_5_TITLE": "Servidores Próprios (Docker)",
"FEATURE_5_DESC": "Os nossos servidores são gratuitos, mas pode assumir o controlo total e iniciar o seu próprio servidor privado em segundos com Docker.",
"FEATURE_6_TITLE": "Convites Rápidos / Entrar com 1 Clique",
"FEATURE_6_DESC": "Nada de senhas complicadas. Partilhe o link e os seus amigos entram na sala automaticamente.",
"FEATURE_7_TITLE": "Compressor de Áudio Integrado",
"FEATURE_7_DESC": "O compressor de áudio reforça os diálogos suaves e baixa o volume das explosões. Funciona localmente, nenhum áudio sai do seu computador.",
"FEATURE_8_TITLE": "Código Aberto (Licença MIT)",
"FEATURE_8_DESC": "Todo o código é público no GitHub. 100% de transparência, sem software oculto.",
"COMP_TITLE": "KoalaSync vs Teleparty",
"COMP_SUBTITLE": "Descubra por que a nossa alternativa aberta e sem anúncios é melhor.",
"COMP_COL_FEATURE": "Funcionalidade",
"COMP_FEAT_1_NAME": "Custos / Paywalls",
"COMP_FEAT_1_DESC": "Assinaturas premium, taxas ocultas ou funcionalidades bloqueadas.",
"COMP_FEAT_1_DESC": "Assinaturas premium ou funcionalidades pagas.",
"COMP_FEAT_1_KOALA": "100% Gratuito",
"COMP_FEAT_1_TELE": "Planos Pagos / Bloqueios Premium",
"COMP_FEAT_2_NAME": "Licença / Código Auditável",
"COMP_FEAT_2_DESC": "Se o código-fonte é aberto e livremente auditável.",
"COMP_FEAT_2_NAME": "Código Aberto",
"COMP_FEAT_2_DESC": "Transparência do código fonte.",
"COMP_FEAT_2_KOALA": "Código Aberto (MIT)",
"COMP_FEAT_2_TELE": "Proprietário",
"COMP_FEAT_3_NAME": "Self-Hosting",
"COMP_FEAT_3_DESC": "Capacidade de implementar o seu próprio servidor de retransmissão privado.",
"COMP_FEAT_3_KOALA": "Sim (Pronto para Docker)",
"COMP_FEAT_3_DESC": "Pode usar o seu próprio servidor.",
"COMP_FEAT_3_KOALA": "Sim (Docker)",
"COMP_FEAT_3_TELE": "Não",
"COMP_FEAT_4_NAME": "Privacidade e Rastreio",
"COMP_FEAT_4_DESC": "Requisitos de registo, cookies de rastreio e análises.",
"COMP_FEAT_4_KOALA": "Zero-Persistência (Apenas RAM)",
"COMP_FEAT_4_DESC": "Recolha de dados e cookies.",
"COMP_FEAT_4_KOALA": "Nenhum dado guardado",
"COMP_FEAT_4_TELE": "Google Analytics e Cookies",
"COMP_FEAT_5_NAME": "Compatibilidade de Sites",
"COMP_FEAT_5_DESC": "Websites suportados e compatibilidade do leitor.",
"COMP_FEAT_5_NAME": "Compatibilidade",
"COMP_FEAT_5_DESC": "Sites de vídeo suportados.",
"COMP_FEAT_5_KOALA": "Quase qualquer vídeo HTML5",
"COMP_FEAT_5_TELE": "Apenas sites suportados",
"COMP_FEAT_6_NAME": "Localização / Tradução",
"COMP_FEAT_6_DESC": "Idiomas de interface disponíveis e suporte de tradução.",
"COMP_FEAT_6_KOALA": "13 Idiomas (e a crescer)",
"COMP_FEAT_5_TELE": "Apenas sites selecionados",
"COMP_FEAT_6_NAME": "Traduzido",
"COMP_FEAT_6_DESC": "Idiomas suportados.",
"COMP_FEAT_6_KOALA": "15 Idiomas",
"COMP_FEAT_6_TELE": "Apenas Inglês",
"COMP_FEAT_7_NAME": "Compressor de Áudio / Nivelamento de Volume",
"COMP_FEAT_7_DESC": "Normalização de áudio integrada para equilibrar diálogos baixos e efeitos altos",
"COMP_FEAT_7_KOALA": "Sim (4 predefinições, controlo dry/wet)",
"COMP_FEAT_7_NAME": "Compressor de Áudio",
"COMP_FEAT_7_DESC": "Melhora a clareza das vozes nos vídeos.",
"COMP_FEAT_7_KOALA": "Sim (4 predefinições)",
"COMP_FEAT_7_TELE": "Não",
"COMP_FOOTNOTE_1": "Estado da comparação: Maio de 2026.",
"COMP_FOOTNOTE_2": "Preços oficiais do Teleparty Premium e detalhes das redes suportadas:",
"COMP_FOOTNOTE_3": "Políticas de privacidade do Teleparty e recolha de dados de rastreio:",
"COMP_FOOTNOTE_4": "Funciona em websites que permitam injeções de scripts nas tags de vídeo HTML5 padrão. Websites com Políticas de Segurança de Conteúdo (CSP) muito rígidas, proteção de direitos de cópia DRM ou wrappers de leitores fortemente ofuscados (como shadow DOMs complexos) podem restringir o controlo ou injeção.",
"COMP_FOOTNOTE_1": "Comparação atualizada em: Maio de 2026.",
"COMP_FOOTNOTE_2": "Preços oficiais do Teleparty:",
"COMP_FOOTNOTE_3": "Políticas de privacidade do Teleparty:",
"COMP_FOOTNOTE_4": "Funciona em websites que permitem a injeção de scripts. Plataformas com DRM estrito podem ter limitações.",
"STEPS_TITLE": "Como Começar",
"STEP_1_TITLE": "Instalar Extensão",
"STEP_1_DESC": "Adicione o KoalaSync ao seu navegador a partir da Chrome Web Store, Firefox Add-ons ou transfira o ZIP de programador mais recente do GitHub.",
"STEP_1_ILLUS_DESC": "Sincronizador de vídeo focado na privacidade",
"STEP_1_TITLE": "Instalar a Extensão",
"STEP_1_DESC": "Adicione o KoalaSync ao seu navegador (Chrome ou Firefox).",
"STEP_1_ILLUS_DESC": "Extensão segura para ver vídeos",
"STEP_1_ILLUS_DL_CHROME": "Descarregar para Chrome",
"STEP_1_ILLUS_DL_FIREFOX": "Descarregar para Firefox",
"STEP_1_ILLUS_ACTIVE": "Extensão Ativa",
"STEP_1_ILLUS_READY": "Pronto para verem juntos!",
"STEP_1_ILLUS_READY": "Pronto!",
"STEP_2_TITLE": "Criar uma Sala",
"STEP_2_DESC": "Abra o popup da extensão e clique em '+ Criar Nova Sala'. O KoalaSync gera automaticamente um ID de Sala e Palavra-passe seguros, entra nela e copia o link de convite para a área de transferência.",
"STEP_2_DESC": "Abra a extensão e clique em '+ Criar Nova Sala'. O link de convite é copiado automaticamente.",
"STEP_2_ILLUS_ROOM": "Sala",
"STEP_2_ILLUS_SYNC": "Sincronização",
"STEP_2_ILLUS_SYNC": "Sincronia",
"STEP_2_ILLUS_SETTINGS": "Definições",
"STEP_2_ILLUS_CREATE": "+ Criar Nova Sala",
"STEP_2_ILLUS_MANUAL": "Ligação Manual / Avançado",
"STEP_2_ILLUS_COPIED": "Link de convite copiado!",
"STEP_2_ILLUS_MANUAL": "Manual / Avançado",
"STEP_2_ILLUS_COPIED": "Link copiado!",
"STEP_3_TITLE": "Partilhar e Sincronizar",
"STEP_3_DESC": "Envie o link de convite para os seus amigos. Assim que eles entrarem, selecione o seu separador do vídeo e desfrute da reprodução sincronizada.",
"STEP_3_ILLUS_IN_SYNC": "EM SINCRONIA",
"SELF_TITLE": "Para Auto-Hospedadores",
"SELF_SUBTITLE": "Não confia no nosso servidor? Mantenha a total soberania dos seus dados. Implemente o seu próprio servidor de retransmissão privado em minutos.",
"SELF_MASCOT_ALT": "Um coala fofo sentado num portátil a implementar um contentor Docker para auto-hospedagem",
"STEP_3_DESC": "Envie o link aos amigos. Escolha um vídeo e desfrutem da reprodução juntos.",
"STEP_3_ILLUS_IN_SYNC": "SINCRONIZADOS",
"SELF_TITLE": "Para utilizadores avançados",
"SELF_SUBTITLE": "Prefere gerir os seus dados? Inicie o seu próprio servidor em minutos com Docker.",
"SELF_MASCOT_ALT": "Um coala a configurar um servidor Docker",
"SELF_COPY_CODE": "Copiar Código",
"SELF_GITHUB_PACKAGES": "Ver todas as tags de imagens no GitHub Packages",
"BOTTOM_TITLE": "Não está convencido? Veja por si mesmo.",
"BOTTOM_SUBTITLE": "O KoalaSync é 100% de código aberto, sem anúncios e sem rastreio. Audite a nossa base de código no GitHub ou instale a extensão do navegador diretamente.",
"BOTTOM_MASCOT_ALT": "Um coala fofo a segurar numa página de repositório GitHub ao lado da mascote do GitHub Octocat",
"SELF_GITHUB_PACKAGES": "Ver pacotes no GitHub",
"BOTTOM_TITLE": "Ainda tem dúvidas?",
"BOTTOM_SUBTITLE": "O KoalaSync é 100% de código aberto, sem anúncios e sem rastreio. Inspecione o nosso código no GitHub.",
"BOTTOM_MASCOT_ALT": "Um coala junto ao logótipo do GitHub",
"FAQ_TITLE": "Perguntas Frequentes",
"FAQ_SUBTITLE": "Tudo o que precisa de saber sobre ver vídeos juntos numa sincronização perfeita.",
"FAQ_Q1": "Posso ver Netflix, Emby ou Jellyfin juntos em sincronia com os amigos?",
"FAQ_A1": "Sim! O KoalaSync funciona no Netflix, YouTube, Twitch e em qualquer website com um leitor de vídeo HTML5 — incluindo Emby, Jellyfin e servidores de media auto-hospedados. Instale a extensão, crie uma sala, partilhe o link de convite e todos assistem em perfeita sincronia.",
"FAQ_Q2": "O KoalaSync é uma alternativa gratuita ao Teleparty?",
"FAQ_A2": "100% gratuito e de código aberto sob a Licença MIT. Sem planos premium, sem funcionalidades bloqueadas, sem anúncios — tudo incluído. Ao contrário do Teleparty, o servidor do KoalaSync corre apenas em RAM, sem qualquer recolha de dados ou necessidade de contas de utilizador.",
"FAQ_Q3": "O KoalaSync funciona com Emby, Jellyfin e servidores de media auto-hospedados?",
"FAQ_A3": "Sim. O KoalaSync deteta qualquer elemento de vídeo HTML5 padrão, o que significa que funciona perfeitamente com Emby, Jellyfin, Plex e qualquer configuração de streaming personalizada. Também pode auto-hospedar o servidor de retransmissão do KoalaSync via Docker para total soberania dos dados.",
"FAQ_Q4": "Que navegadores e plataformas suportam o KoalaSync?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave e todos os navegadores baseados em Chromium. Instale diretamente da Chrome Web Store ou Firefox Add-ons — sem necessidade de configuração manual.",
"FAQ_Q5": "Todos os meus amigos precisam de ter a extensão instalada?",
"FAQ_A5": "Sim, cada participante precisa de ter o KoalaSync instalado — mas não há registo ou criação de conta. Basta clicar no link de convite, instalar a extensão se ainda não o fez, e entrará na sala automaticamente em segundos.",
"FAQ_Q6": "Os meus dados estão seguros? E se eu não confiar no servidor oficial?",
"FAQ_A6": "Totalmente seguro. O servidor de retransmissão do KoalaSync corre apenas em RAM — todos os dados da sessão são permanentemente eliminados no momento em que sai da sala. Sem base de dados, sem registo, sem análises, sem contas. E se preferir o controlo total: pode auto-hospedar o seu próprio servidor de retransmissão privado via Docker em minutos. Toda a base de código é aberta e auditável no GitHub.",
"FAQ_Q7": "Posso corrigir diálogos suaves e explosões altas ao ver vídeos no navegador?",
"FAQ_A7": "Sim. O KoalaSync 2.2.0 inclui um compressor de áudio integrado que reduz a diferença entre as partes silenciosas e barulhentas do seu vídeo. Funciona localmente através da Web Audio API — nenhum dado de áudio sai do seu computador. Abra o popup, crie uma sala, vá a Definições → Audio Processing, ative o compressor e escolha um preset. Funciona na Netflix, Prime Video, Disney+, YouTube e qualquer site de vídeo HTML5.",
"HOWTO_TITLE": "Como sincronizar vídeos com amigos usando o KoalaSync",
"HOWTO_DESC": "Veja qualquer vídeo em sincronia perfeita com os amigos no YouTube, Netflix, Twitch e mais. Sem registo, sem rastreio.",
"HOWTO_STEP_1_NAME": "Instale a extensão do navegador",
"HOWTO_STEP_1_TEXT": "Adicione o KoalaSync ao seu navegador a partir da Chrome Web Store ou Firefox Add-ons. Funciona em qualquer website com um elemento de vídeo.",
"FAQ_SUBTITLE": "Tudo o que precisa de saber.",
"FAQ_Q1": "Posso ver a Netflix ou YouTube com os meus amigos?",
"FAQ_A1": "Sim! O KoalaSync funciona no Netflix, YouTube, Twitch e qualquer site com vídeo HTML5. Instale a extensão, partilhe o link e está feito.",
"FAQ_Q2": "É gratuito?",
"FAQ_A2": "Sim, é 100% gratuito e de código aberto. Sem funções premium bloqueadas e sem publicidade.",
"FAQ_Q3": "Funciona com Emby ou Jellyfin?",
"FAQ_A3": "Sim. Funciona perfeitamente em servidores multimédia próprios como Emby, Jellyfin e Plex.",
"FAQ_Q4": "Que navegadores suporta?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Edge, Opera, Brave e outros baseados em Chromium.",
"FAQ_Q5": "Os meus amigos precisam de instalar a extensão?",
"FAQ_A5": "Sim, cada pessoa precisa da extensão, mas não é necessário criar uma conta. Basta um clique no link de convite.",
"FAQ_Q6": "Os meus dados estão seguros?",
"FAQ_A6": "Sim. O nosso servidor usa apenas RAM. Não guardamos nada. Os dados são eliminados quando a sala fecha.",
"FAQ_Q7": "Posso normalizar o áudio para ouvir melhor os diálogos?",
"FAQ_A7": "Sim! O KoalaSync tem um compressor de áudio que regula as vozes baixas e atenua as explosões. Ative-o nas Definições de Processamento de Áudio.",
"HOWTO_TITLE": "Como usar o KoalaSync",
"HOWTO_DESC": "Sincronize vídeos no YouTube, Netflix e Twitch sem criar conta.",
"HOWTO_STEP_1_NAME": "Instale a extensão",
"HOWTO_STEP_1_TEXT": "Adicione o KoalaSync ao Chrome ou Firefox.",
"HOWTO_STEP_2_NAME": "Crie uma sala",
"HOWTO_STEP_2_TEXT": "Abra o popup da extensão e clique em Criar Nova Sala. O link de convite é copiado automaticamente para a área de transferência.",
"HOWTO_STEP_3_NAME": "Partilhe e sincronize",
"HOWTO_STEP_3_TEXT": "Envie o link de convite para os seus amigos. Escolha um separador do vídeo e desfrute da reprodução sincronizada em tempo real.",
"HOWTO_STEP_2_TEXT": "Abra a extensão e crie uma sala. O link será copiado.",
"HOWTO_STEP_3_NAME": "Sincronize",
"HOWTO_STEP_3_TEXT": "Envie o link aos amigos e vejam o vídeo em simultâneo.",
"FOOTER_MIT": "Código aberto sob a Licença MIT.",
"FOOTER_RAM": "Nenhum dado é guardado nos nossos servidores. Transmissão baseada apenas em RAM.",
"FOOTER_RAM": "Sem dados guardados. Baseado em RAM.",
"FOOTER_LEGAL": "Aviso Legal",
"FOOTER_PRIVACY": "Política de Privacidade",
"FOOTER_PRIVACY": "Privacidade",
"MOCK_01": "Sala Ativa",
"MOCK_02": "Servidor Oficial",
"MOCK_03": "Link de Convite",
"MOCK_04": "Participantes na Sala",
"MOCK_05": "YOU",
"MOCK_06": "Playing",
"MOCK_07": "Peer",
"MOCK_05": "TU",
"MOCK_06": "A reproduzir",
"MOCK_07": "Participante",
"MOCK_08": "Sair da Sala",
"MOCK_09": "Selecionar Vídeo",
"MOCK_10": "Controlo Remoto",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Play",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pause",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SYNC",
"MOCK_14": "Ir para os Outros",
"MOCK_15": "Estado da Última Atividade",
"MOCK_16": "A aguardar por 1 participante (KoalaPC)...",
"MOCK_17": "Ignorar e reproduzir mesmo assim",
"MOCK_18": "O seu Nome de Utilizador",
"MOCK_19": "Ocultar Separadores Desnecessários",
"MOCK_20": "Sincronização Automática do Próximo Episódio",
"MOCK_21": "Copiar Automaticamente o Link de Convite",
"MOCK_22": "Notificações do Navegador",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Pausa",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SINCRO",
"MOCK_14": "Ir para a posição dos outros",
"MOCK_15": "Última Atividade",
"MOCK_16": "À espera de 1 amigo (KoalaPC)...",
"MOCK_17": "Ignorar e reproduzir agora",
"MOCK_18": "O seu Nome",
"MOCK_19": "Ocultar separadores sem vídeo",
"MOCK_20": "Sincro auto do próximo episódio",
"MOCK_21": "Copiar Convite Auto",
"MOCK_22": "Notificações",
"MOCK_23": "Resolução de Problemas",
"MOCK_24": "Regerar ID do Peer",
"MOCK_25": "Use isto se vir erros de \"Identidade Duplicata\".",
"MOCK_26": "Estado da Ligação",
"MOCK_27": "Connected",
"MOCK_24": "Regerar ID",
"MOCK_25": "Corrige erros de 'Identidade Duplicada'.",
"MOCK_26": "Estado",
"MOCK_27": "Ligado",
"MOCK_28": "Copiar Registos",
"MOCK_29": "Info de Debug de Vídeo",
"MOCK_30": "Histórico Completo de Ações",
"MOCK_31": "Registos (Últimos 50)",
"MOCK_29": "Debug de Vídeo",
"MOCK_30": "Histórico Completo",
"MOCK_31": "Registos",
"MOCK_32": "LIMPAR",
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync não é afiliado, endossado ou associado à Netflix, Disney+, Amazon, YouTube, Twitch ou qualquer outra plataforma de streaming. Todas as marcas registadas são propriedade dos seus respetivos titulares.",
"FOOTER_SUPPORT": "Oferece um café no Ko-Fi"
"FOOTER_DISCLAIMER": "KoalaSync não é afiliado à Netflix, Disney+, YouTube, Twitch, etc. Todas as marcas são propriedade dos seus titulares.",
"FOOTER_SUPPORT": "Support KoalaSync"
}
+6 -5
View File
@@ -7,6 +7,7 @@
"OG_LOCALE": "ru_RU",
"META_TITLE": "KoalaSync | Синхронизация Netflix, YouTube и любого видео с друзьями",
"META_DESCRIPTION": "Смотрите Netflix, YouTube, Twitch и любые HTML5-видео в синхронизации с друзьями. Бесплатное расширение браузера с открытым кодом. Регистрация не требуется.",
"SCHEMA_APP_DESC": "Смотрите Netflix, YouTube, Twitch и любые HTML5-видео в синхронизации с друзьями. Бесплатное расширение браузера с открытым кодом. Регистрация не требуется.",
"OG_TITLE": "KoalaSync | Синхронизация Netflix, Emby, Jellyfin и почти любого видео с друзьями",
"OG_DESCRIPTION": "Смотрите Netflix, Emby, Jellyfin, YouTube, Twitch и почти любое HTML5-видео в идеальной синхронизации. Открытое и ориентированное на конфиденциальность расширение для Chrome и Firefox.",
"TWITTER_TITLE": "KoalaSync | Синхронизация Netflix, Emby, Jellyfin и почти любого видео с друзьями – Расширение",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "Только поддерживаемые сайты",
"COMP_FEAT_6_NAME": "Локализация / Перевод",
"COMP_FEAT_6_DESC": "Доступные языки интерфейса и поддержка перевода.",
"COMP_FEAT_6_KOALA": "13 языков (и расширяется)",
"COMP_FEAT_6_KOALA": "15 языков (и расширяется)",
"COMP_FEAT_6_TELE": "Только английский",
"COMP_FEAT_7_NAME": "Аудиокомпрессор / Выравнивание Громкости",
"COMP_FEAT_7_DESC": "Встроенная нормализация звука для баланса тихих диалогов и громких эффектов",
@@ -150,9 +151,9 @@
"MOCK_08": "Покинуть комнату",
"MOCK_09": "Выбрать видео",
"MOCK_10": "Пульт управления",
"MOCK_11": "Воспроизвести",
"MOCK_12": "Пауза",
"MOCK_13": "Перейти к другим",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Воспроизвести",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Пауза",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> SYNC",
"MOCK_14": "Последняя активность",
"MOCK_15": "Ожидание 1 участника (KoalaPC)...",
"MOCK_16": "Пропустить и играть",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync не связан, не спонсируется и не аффилирован с Netflix, Disney+, Amazon, YouTube, Twitch или любой другой стриминговой платформой. Все товарные знаки являются собственностью их соответствующих владельцев.",
"FOOTER_SUPPORT": "Угости кофе на Ko-Fi"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+4 -3
View File
@@ -2,11 +2,12 @@
"LANG_CODE": "tr",
"HTML_CLASS": "lang-tr",
"CANONICAL_PATH": "tr/",
"LANG_TOGGLE_URL": "",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "tr_TR",
"META_TITLE": "KoalaSync | Netflix, YouTube ve Herhangi Bir Videoyu Arkadaşlarınızla Eşitleyin",
"META_DESCRIPTION": "Netflix, YouTube, Twitch ve herhangi bir HTML5 videoyu arkadaşlarınızla mükemmel uyum içinde izleyin. Chrome ve Firefox için ücretsiz, açık kaynaklı tarayıcı eklentisi. Kayıt gerekmez.",
"SCHEMA_APP_DESC": "Netflix, YouTube, Twitch ve herhangi bir HTML5 videoyu arkadaşlarınızla mükemmel uyum içinde izleyin. Chrome ve Firefox için ücretsiz, açık kaynaklı tarayıcı eklentisi. Kayıt gerekmez.",
"OG_TITLE": "KoalaSync | Netflix, Emby, Jellyfin ve Neredeyse Her Videoyu Arkadaşlarınızla Eşitleyin",
"OG_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch ve neredeyse tüm HTML5 videolarını mükemmel bir senkronizasyonla izleyin. Chrome ve Firefox için gizlilik öncelikli, açık kaynaklı tarayıcı eklentisi.",
"TWITTER_TITLE": "KoalaSync | Netflix, Emby, Jellyfin ve Neredeyse Her Videoyu Arkadaşlarla Eşitle Tarayıcı Eklentisi",
@@ -75,7 +76,7 @@
"COMP_FEAT_5_TELE": "Sadece desteklenen siteler",
"COMP_FEAT_6_NAME": "Yerelleştirme / Çeviri",
"COMP_FEAT_6_DESC": "Mevcut arayüz dilleri ve çeviri desteği.",
"COMP_FEAT_6_KOALA": "13 Dil (ve artıyor)",
"COMP_FEAT_6_KOALA": "15 Dil (ve artıyor)",
"COMP_FEAT_6_TELE": "Sadece İngilizce",
"COMP_FEAT_7_NAME": "Ses Kompresörü / Ses Seviyesi Dengeleme",
"COMP_FEAT_7_DESC": "Sessiz diyaloglar ve yüksek efektler arasında denge sağlayan yerleşik ses normalizasyonu",
@@ -175,5 +176,5 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync; Netflix, Disney+, Amazon, YouTube, Twitch veya başka herhangi bir yayın platformuna bağlı değildir, bunlar tarafından desteklenmemektedir ve bunlarla ilişkili değildir. Tüm ticari markalar ilgili sahiplerinin mülkiyetindedir.",
"FOOTER_SUPPORT": "Ko-Fi'de bana kahve ısmarla"
"FOOTER_SUPPORT": "Support KoalaSync"
}
+180
View File
@@ -0,0 +1,180 @@
{
"LANG_CODE": "uk",
"HTML_CLASS": "lang-uk",
"CANONICAL_PATH": "uk/",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "uk_UA",
"META_TITLE": "KoalaSync | Синхронізуйте Netflix, YouTube і будь-яке відео з друзями",
"META_DESCRIPTION": "Дивіться Netflix, YouTube, Twitch і будь-яке HTML5 відео в ідеальній синхронізації з друзями. Безкоштовне розширення для браузера з відкритим кодом для Chrome і Firefox. Реєстрація не потрібна.",
"SCHEMA_APP_DESC": "Дивіться Netflix, YouTube, Twitch і будь-яке HTML5 відео в ідеальній синхронізації з друзями. Безкоштовне розширення для браузера з відкритим кодом для Chrome і Firefox. Реєстрація не потрібна.",
"OG_TITLE": "KoalaSync | Синхронізація Netflix, Emby, Jellyfin і майже будь-якого відео з друзями",
"OG_DESCRIPTION": "Дивіться Netflix, Emby, Jellyfin, YouTube, Twitch і майже будь-яке відео HTML5 в ідеальній синхронізації. Розширення веб-переглядача з відкритим вихідним кодом для Chrome і Firefox, перш за все конфіденційність.",
"TWITTER_TITLE": "KoalaSync | Синхронізація Netflix, Emby, Jellyfin і майже будь-якого відео з друзями – розширення браузера",
"TWITTER_DESCRIPTION": "Дивіться Netflix, Emby, Jellyfin, YouTube, Twitch і майже будь-яке відео HTML5 в ідеальній синхронізації з друзями. Розширення веб-переглядача з відкритим вихідним кодом для Chrome і Firefox, перш за все конфіденційність.",
"NAV_FEATURES": "особливості",
"NAV_HOW_IT_WORKS": "Як це працює",
"HERO_TITLE": "Дивіться разом.<span class='hero-line2'>Ідеальна синхронізація.</span>",
"HERO_SUBTITLE": "Ваш віддалений кіновечір без лагів. Ні реєстрації, ні збору даних. Просто поділіться посиланням і дивіться разом.",
"HERO_MASCOT_ALT": "Мила коала стоїть і дивиться вниз на кнопки завантаження",
"ADD_TO_CHROME": "Додати до Chrome",
"ADD_TO_FIREFOX": "Додати до Firefox",
"COMPAT_HEADING": "Працює на ваших улюблених платформах",
"COMPAT_MORE": "і багато іншого",
"COMPAT_TOOLTIP": "Працює майже на будь-якому сайті з елементом відео",
"USE_CASES_TITLE": "Ідеально підходить для будь-якого випадку",
"USE_CASES_SUBTITLE": "Незалежно від того, поблизу чи далеко, KoalaSync об’єднує людей навколо їхніх улюблених відео.",
"USE_CASE_1_ALT": "Дві милі коали сидять разом і діляться відерцем попкорну",
"USE_CASE_1_TITLE": "Вечір кіно з друзями",
"USE_CASE_1_DESC": "Синхронізуйте свої фільми в режимі реального часу та спілкуйтеся через Discord, Zoom або улюблену програму для голосових викликів. Таке відчуття, ніби сидиш в одній кімнаті.",
"USE_CASE_2_ALT": "Професор-коала презентує на екрані двом віддаленим студентам-коалам",
"USE_CASE_2_TITLE": "Дистанційне навчання",
"USE_CASE_2_DESC": "Аналізуйте онлайн-уроки, лекції чи навчальні потоки для розробників разом з однокласниками чи колегами. Зробіть паузу та обговоріть складні кроки в ідеальній гармонії.",
"USE_CASE_3_ALT": "Мила пара коал дивиться разом віддалено; один сидить за комп’ютером, а інший у ліжку з ноутбуком, а між ними літають серця",
"USE_CASE_3_TITLE": "Відносини на відстані",
"USE_CASE_3_DESC": "Подолайте відстань і насолоджуйтесь побаченнями. Переживайте кожен поворот сюжету, смійтеся над тими самими жартами та діліться емоційними моментами в ту саму мілісекунду.",
"WHY_TITLE": "Чому KoalaSync?",
"WHY_SUBTITLE": "Створено для надійної синхронізації, конфіденційності та простого налаштування.",
"FEATURE_1_TITLE": "Повний контроль/миттєва синхронізація",
"FEATURE_1_DESC": "Один робить паузу, всі зупиняються. Один шукає, всі йдуть. Наш двофазний протокол синхронізації координує відтворення в режимі реального часу для всіх учасників.",
"FEATURE_2_TITLE": "Нескінченне споглядання",
"FEATURE_2_DESC": "Автоматичне відтворення синхронізовано. KoalaSync автоматично визначає переходи епізодів і утримує відтворення, доки всі однорангові пристрої успішно не завантажать наступне відео.",
"FEATURE_3_TITLE": "Нуль облікових записів / чиста конфіденційність",
"FEATURE_3_DESC": "Ні реєстрації, ні відстеження, ні слідів постійності. Сервер повністю працює в ефемерному RAM і очищає вашу кімнату, коли ви залишаєте її.",
"FEATURE_4_TITLE": "Універсальна підтримка HTML5",
"FEATURE_4_DESC": "Працює на YouTube, Twitch, Netflix, Jellyfin, Emby і майже будь-якій стандартній веб-сторінці, що містить відеоелемент HTML5. Також сумісний із користувальницькими налаштуваннями, розміщеними на власному хості. На наші сервери ніколи не надсилаються відеодані — лише метадані синхронізації.",
"FEATURE_5_TITLE": "Саморозміщення та готовність до Docker",
"FEATURE_5_DESC": "Наші офіційні сервери безкоштовні та швидкі, але ви можете отримати повне право власності, якщо хочете. Запустіть власний приватний сервер ретрансляції за лічені секунди через Docker.",
"FEATURE_6_TITLE": "Миттєві запрошення / Приєднання в 1 клік",
"FEATURE_6_DESC": "Немає IP-адрес або паролів для обміну. Поділіться створеним посиланням із запрошенням із друзями, щоб вони могли автоматично приєднатися до вашої кімнати одним клацанням миші.",
"FEATURE_7_TITLE": "Вирівнювання гучності / Аудіокомпресор",
"FEATURE_7_DESC": "Більше не потрібно натискати на ручку гучності. Вбудований аудіокомпресор посилює тихий діалог і приборкує гучні вибухи. Працює на Netflix, Prime Video, Disney+, YouTube і будь-якому відео HTML5. Повністю локально — аудіодані не залишають ваш комп’ютер.",
"FEATURE_8_TITLE": "Відкритий код (ліцензія MIT)",
"FEATURE_8_DESC": "Вся кодова база доступна для публічного аудиту на GitHub. Жодних чорних скриньок, жодних власних блобів — 100% прозорість від сервера ретрансляції до розширення браузера.",
"COMP_TITLE": "KoalaSync проти Teleparty",
"COMP_SUBTITLE": "Дізнайтеся, чому відкритий вихідний код, без реклами та конфіденційність – це найкращий спосіб дивитися разом.",
"COMP_COL_FEATURE": "Особливість",
"COMP_FEAT_1_NAME": "Вартість / Paywalls",
"COMP_FEAT_1_DESC": "Преміум-підписки, приховані комісії або заблоковані функції.",
"COMP_FEAT_1_KOALA": "100% безкоштовно",
"COMP_FEAT_1_TELE": "Платні рівні / преміум-блокування",
"COMP_FEAT_2_NAME": "Ліцензія/Код підлягає аудиту",
"COMP_FEAT_2_DESC": "Чи є вихідний код відкритим і доступним для вільного аудиту.",
"COMP_FEAT_2_KOALA": "Відкритий код (MIT)",
"COMP_FEAT_2_TELE": "Власний",
"COMP_FEAT_3_NAME": "Самостійне розміщення",
"COMP_FEAT_3_DESC": "Можливість розгорнути власний приватний сервер ретрансляції.",
"COMP_FEAT_3_KOALA": "Так (готовий до Docker)",
"COMP_FEAT_3_TELE": "немає",
"COMP_FEAT_4_NAME": "Конфіденційність і відстеження",
"COMP_FEAT_4_DESC": "Вимоги до реєстрації, відстеження файлів cookie та аналітика.",
"COMP_FEAT_4_KOALA": "Нульова стійкість (лише для RAM)",
"COMP_FEAT_4_TELE": "Google Analytics і файли cookie",
"COMP_FEAT_5_NAME": "Сумісність сайту",
"COMP_FEAT_5_DESC": "Підтримувані веб-сайти та сумісність із плеєрами.",
"COMP_FEAT_5_KOALA": "Майже будь-яке відео HTML5",
"COMP_FEAT_5_TELE": "Лише підтримувані сайти",
"COMP_FEAT_6_NAME": "Локалізація / Переклад",
"COMP_FEAT_6_DESC": "Доступні мови інтерфейсу та підтримка перекладу.",
"COMP_FEAT_6_KOALA": "15 мов (і зростає)",
"COMP_FEAT_6_TELE": "лише англійською мовою",
"COMP_FEAT_7_NAME": "Аудіокомпресор / Вирівнювання гучності",
"COMP_FEAT_7_DESC": "Вбудована нормалізація аудіо для балансування тихих діалогів і гучних ефектів",
"COMP_FEAT_7_KOALA": "Так (4 попередні налаштування, сухий/мокрий контроль)",
"COMP_FEAT_7_TELE": "немає",
"COMP_FOOTNOTE_1": "Стан для порівняння: травень 2026 року.",
"COMP_FOOTNOTE_2": "Офіційні ціни на Teleparty Premium і відомості про підтримувані мережі:",
"COMP_FOOTNOTE_3": "Політика конфіденційності Teleparty і збір даних відстеження:",
"COMP_FOOTNOTE_4": "Працює на веб-сайтах, які дозволяють ін’єкції сценаріїв у стандартні відеотеги HTML5. Веб-сайти з дуже суворими політиками безпеки вмісту (CSP), захистом від копіювання DRM або сильно затуманеними оболонками програвачів (як-от складні тіньові DOM) можуть обмежувати автоматизований контроль або впровадження.",
"STEPS_TITLE": "Початок роботи",
"STEP_1_TITLE": "Встановити розширення",
"STEP_1_DESC": "Додайте KoalaSync до свого браузера з веб-магазину Chrome, доповнень Firefox або завантажте найновіший розробник ZIP із GitHub.",
"STEP_1_ILLUS_DESC": "Синхронізатор відео насамперед із конфіденційністю",
"STEP_1_ILLUS_DL_CHROME": "Завантажити для Chrome",
"STEP_1_ILLUS_DL_FIREFOX": "Завантажити для Firefox",
"STEP_1_ILLUS_ACTIVE": "Розширення активне",
"STEP_1_ILLUS_READY": "Готові дивитися разом!",
"STEP_2_TITLE": "Створіть кімнату",
"STEP_2_DESC": "Відкрийте спливаюче вікно розширення та натисніть «+ Створити нову кімнату». KoalaSync автоматично генерує безпечний Room ID і пароль, приєднується до нього та копіює посилання для запрошення в буфер обміну.",
"STEP_2_ILLUS_ROOM": "Кімната",
"STEP_2_ILLUS_SYNC": "Синхронізувати",
"STEP_2_ILLUS_SETTINGS": "Налаштування",
"STEP_2_ILLUS_CREATE": "+ Створити нову кімнату",
"STEP_2_ILLUS_MANUAL": "Ручне підключення / Додатково",
"STEP_2_ILLUS_COPIED": "Посилання на запрошення скопійовано!",
"STEP_3_TITLE": "Поділитися та синхронізувати",
"STEP_3_DESC": "Надішліть запрошення своїм друзям. Коли вони приєднаються, виберіть вкладку відео та насолоджуйтеся синхронізованим відтворенням.",
"STEP_3_ILLUS_IN_SYNC": "СИНХРОНІЗОВАНО",
"SELF_TITLE": "Для самостійних господарів",
"SELF_SUBTITLE": "Не довіряєте нашому серверу? Підтримуйте повний суверенітет даних. Розгорніть власний приватний сервер ретрансляції за лічені хвилини.",
"SELF_MASCOT_ALT": "Мила коала сидить за ноутбуком і розгортає контейнер Docker для самостійного розміщення",
"SELF_COPY_CODE": "Копіювати код",
"SELF_GITHUB_PACKAGES": "Переглянути всі теги зображень на пакетах GitHub",
"BOTTOM_TITLE": "Не переконали? Подивіться самі.",
"BOTTOM_SUBTITLE": "KoalaSync є на 100% відкритим кодом, без реклами та відстеження. Перевірте нашу кодову базу на GitHub або безпосередньо встановіть розширення для браузера.",
"BOTTOM_MASCOT_ALT": "Мила коала тримає сторінку репозиторію GitHub поруч із талісманом GitHub Octocat",
"FAQ_TITLE": "Часті запитання",
"FAQ_SUBTITLE": "Все, що вам потрібно знати про спільний перегляд відео в ідеальній синхронізації.",
"FAQ_Q1": "Чи можу я дивитися Netflix, Emby або Jellyfin разом із друзями?",
"FAQ_A1": "так! KoalaSync працює на Netflix, YouTube, Twitch і будь-якому веб-сайті з відеопрогравачем HTML5, включаючи Emby, Jellyfin і медіасервери, розміщені на власному хості. Установіть розширення, створіть кімнату, поділіться посиланням для запрошення, і всі дивитимуться в повній синхронізації.",
"FAQ_Q2": "Чи є KoalaSync безкоштовною альтернативою Teleparty?",
"FAQ_A2": "100% безкоштовне та з відкритим кодом за ліцензією MIT. Без преміум-рівнів, без заблокованих функцій, без реклами — все включено. На відміну від Teleparty, ретрансляційний сервер KoalaSync призначений лише для RAM і не вимагає збору даних або облікових записів користувачів.",
"FAQ_Q3": "Чи працює KoalaSync із Emby, Jellyfin і власними медіа-серверами?",
"FAQ_A3": "так KoalaSync виявляє будь-який стандартний відеоелемент HTML5, що означає, що він ідеально працює з Emby, Jellyfin, Plex та будь-якими спеціальними налаштуваннями потокового передавання. Ви також можете самостійно розмістити сервер ретрансляції KoalaSync через Docker для повного суверенітету даних.",
"FAQ_Q4": "Які браузери та платформи підтримують KoalaSync?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave та всі браузери на основі Chromium. Встановіть його безпосередньо з веб-магазину Chrome або додатків Firefox — ручне налаштування не потрібне.",
"FAQ_Q5": "Чи всім моїм друзям потрібно встановити розширення?",
"FAQ_A5": "Так, кожному учаснику потрібно встановити KoalaSync — але немає реєстрації чи створення облікового запису. Просто клацніть посилання запрошення, установіть розширення, якщо ви ще цього не зробили, і ви автоматично приєднаєтесь до кімнати за лічені секунди.",
"FAQ_Q6": "Чи мої дані в безпеці? Що робити, якщо я не довіряю офіційному серверу?",
"FAQ_A6": "Повністю безпечний. Сервер ретрансляції KoalaSync призначений лише для RAM — усі дані сеансу назавжди видаляються, коли ви залишаєте кімнату. Ні бази даних, ні реєстрації, ні аналітики, ні облікових записів. А якщо ви віддаєте перевагу повному контролю: ви можете самостійно розмістити свій приватний сервер ретрансляції через Docker за лічені хвилини. Уся кодова база є відкритим кодом і доступна дя перевірки на GitHub.",
"FAQ_Q7": "Чи можу я виправити тихий діалог і гучні вибухи під час перегляду відео в браузері?",
"FAQ_A7": "Так. KoalaSync 2.2.0 містить вбудований аудіокомпресор, який зменшує різницю між тихими та гучними частинами відео. Він працює локально через Web Audio API - аудіодані ніколи не залишають ваш комп'ютер. Відкрийте спливне вікно, створіть кімнату, перейдіть до «Налаштування» → «Обробка аудіо», увімкніть компресор і виберіть пресет. Працює з Netflix, Prime Video, Disney+, YouTube і будь-яким HTML5-відеосайтом.",
"HOWTO_TITLE": "Як синхронізувати відео з друзями за допомогою KoalaSync",
"HOWTO_DESC": "Дивіться будь-яке відео в ідеальній синхронізації з друзями на YouTube, Netflix, Twitch тощо. Без реєстрації, без відстеження.",
"HOWTO_STEP_1_NAME": "Встановіть розширення для браузера",
"HOWTO_STEP_1_TEXT": "Додайте KoalaSync до свого браузера з Chrome Web Store або Firefox Add-ons. Він працює на будь-якому веб-сайті з елементом відео.",
"HOWTO_STEP_2_NAME": "Створіть кімнату",
"HOWTO_STEP_2_TEXT": "Відкрийте спливаюче вікно розширення та натисніть «Створити нову кімнату». Посилання для запрошення автоматично копіюється в буфер обміну.",
"HOWTO_STEP_3_NAME": "Поділіться та синхронізуйте",
"HOWTO_STEP_3_TEXT": "Надішліть запрошення своїм друзям. Виберіть вкладку відео та насолоджуйтеся синхронізованим відтворенням у реальному часі.",
"FOOTER_MIT": "Відкритий код за ліцензією MIT.",
"FOOTER_RAM": "На наших серверах дані не зберігаються. Чисте реле на основі RAM.",
"FOOTER_LEGAL": "Юридична інформація",
"FOOTER_PRIVACY": "Політика конфіденційності",
"MOCK_01": "Активна кімната",
"MOCK_02": "Офіційний сервер",
"MOCK_03": "Посилання на запрошення",
"MOCK_04": "Однолітки в кімнаті",
"MOCK_05": "ВИ",
"MOCK_06": "Граючи",
"MOCK_07": "Одноліток",
"MOCK_08": "Вийти з кімнати",
"MOCK_09": "Виберіть Відео",
"MOCK_10": "Пульт дистанційного керування",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> Грати",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> Пауза",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> СИНХ",
"MOCK_14": "Перейти до інших",
"MOCK_15": "Статус останньої дії",
"MOCK_16": "Очікування 1 партнера (KoalaPC)...",
"MOCK_17": "Все одно пропустіть і грайте",
"MOCK_18": "Ваше ім'я користувача",
"MOCK_19": "Приховати безладні вкладки",
"MOCK_20": "Автоматична синхронізація наступного епізоду",
"MOCK_21": "Автоматичне копіювання посилання для запрошення",
"MOCK_22": "Сповіщення браузера",
"MOCK_23": "Усунення несправностей",
"MOCK_24": "Регенерувати Peer ID",
"MOCK_25": "Використовуйте це, якщо ви бачите помилку \"Дублікати ідентифікатора\".",
"MOCK_26": "Статус підключення",
"MOCK_27": "Підключено",
"MOCK_28": "Копіювати журнали",
"MOCK_29": "Інформація про налагодження відео",
"MOCK_30": "Повна історія дій",
"MOCK_31": "Журнали (останні 50)",
"MOCK_32": "ЯСНО",
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync не пов’язаний із Netflix, Disney+, Amazon, YouTube, Twitch або будь-якою іншою потоковою платформою, не схвалений і не пов’язаний з ними. Усі торгові марки є власністю відповідних власників.",
"FOOTER_SUPPORT": "Support KoalaSync"
}
+180
View File
@@ -0,0 +1,180 @@
{
"LANG_CODE": "zh",
"HTML_CLASS": "lang-zh",
"CANONICAL_PATH": "zh/",
"LANG_TOGGLE_URL": "../",
"LANG_TOGGLE_TEXT": "EN",
"OG_LOCALE": "zh_CN",
"META_TITLE": "KoalaSync | 与朋友同步 Netflix、YouTube 和任何视频",
"META_DESCRIPTION": "与朋友完美同步观看 Netflix、YouTube、Twitch 和任何 HTML5 视频。适用于 Chrome 和 Firefox 的免费开源浏览器扩展。无需注册。",
"SCHEMA_APP_DESC": "与朋友完美同步观看 Netflix、YouTube、Twitch 和任何 HTML5 视频。适用于 Chrome 和 Firefox 的免费开源浏览器扩展。无需注册。",
"OG_TITLE": "KoalaSync |与朋友同步 Netflix、Emby、Jellyfin 和几乎任何视频",
"OG_DESCRIPTION": "完美同步观看 Netflix、Emby、Jellyfin、YouTube、Twitch 和几乎任何 HTML5 视频。适用于 Chrome 和 Firefox 的隐私第一开源浏览器扩展。",
"TWITTER_TITLE": "KoalaSync |与朋友同步 Netflix、Emby、Jellyfin 和几乎所有视频 浏览器扩展",
"TWITTER_DESCRIPTION": "与朋友完美同步观看 Netflix、Emby、Jellyfin、YouTube、Twitch 和几乎任何 HTML5 视频。适用于 Chrome 和 Firefox 的隐私第一开源浏览器扩展。",
"NAV_FEATURES": "特征",
"NAV_HOW_IT_WORKS": "它是如何运作的",
"HERO_TITLE": "一起观看.<span class='hero-line2'> 完美同步.</span>",
"HERO_SUBTITLE": "您的远程电影之夜没有延迟。无需注册,无需收集数据。只需分享链接即可一起观看。",
"HERO_MASCOT_ALT": "一只可爱的考拉站着,低头看着下载按钮",
"ADD_TO_CHROME": "添加到Chrome",
"ADD_TO_FIREFOX": "添加到Firefox",
"COMPAT_HEADING": "适用于您最喜欢的平台",
"COMPAT_MORE": "还有更多",
"COMPAT_TOOLTIP": "适用于几乎所有具有视频元素的网站",
"USE_CASES_TITLE": "适合任何场合",
"USE_CASES_SUBTITLE": "无论远近,KoalaSync 将人们聚集在他们最喜欢的视频周围。",
"USE_CASE_1_ALT": "两只可爱的考拉坐在一起分享一桶爆米花",
"USE_CASE_1_TITLE": "与朋友的电影之夜",
"USE_CASE_1_DESC": "实时同步您的电影并通过 Discord、Zoom 或您最喜欢的语音通话应用程序进行交谈。感觉就像坐在同一个房间里一样。",
"USE_CASE_2_ALT": "一位考拉教授在屏幕上向两只远程学生考拉进行演示",
"USE_CASE_2_TITLE": "远程学习",
"USE_CASE_2_DESC": "与同学或同事一起分析在线教程、讲座或开发人员培训流。暂停并完美和谐地讨论复杂的步骤。",
"USE_CASE_3_ALT": "一对可爱的考拉情侣一起远程观看;一个坐在电脑前,另一个拿着笔记本电脑躺在床上,两人的心在飞舞",
"USE_CASE_3_TITLE": "远距离关系",
"USE_CASE_3_DESC": "拉近距离,享受约会之夜。体验每一个情节转折,嘲笑同样的笑话,并在同一毫秒内分享情感时刻。",
"WHY_TITLE": "为什么是KoalaSync",
"WHY_SUBTITLE": "专为可靠的同步、隐私优先的设计和轻松的设置而打造。",
"FEATURE_1_TITLE": "完全控制/即时同步",
"FEATURE_1_DESC": "一个人停顿,所有人都停顿。一追寻,众人追随。我们的两阶段同步协议协调所有参与者的实时播放。",
"FEATURE_2_TITLE": "无尽的狂欢",
"FEATURE_2_DESC": "自动同步播放。 KoalaSync 自动检测剧集转换并保持播放,直到所有对等方都成功加载下一个视频。",
"FEATURE_3_TITLE": "零账户/纯粹隐私",
"FEATURE_3_DESC": "没有注册,没有跟踪,没有持久性足迹。服务器完全在临时 RAM 中运行,并在您离开时清理您的房间。",
"FEATURE_4_TITLE": "通用 HTML5 支持",
"FEATURE_4_DESC": "适用于 YouTube、Twitch、Netflix、Jellyfin、Emby 以及几乎任何包含 HTML5 视频元素的标准网页。还与自定义自托管设置兼容。不会将任何视频数据发送到我们的服务器,仅发送同步元数据。",
"FEATURE_5_TITLE": "自托管和 Docker 就绪",
"FEATURE_5_DESC": "我们的官方服务器是免费且快速的,但如果您愿意,您可以完全拥有所有权。通过 Docker 在几秒钟内启动您自己的私有中继服务器。",
"FEATURE_6_TITLE": "即时邀请/一键加入",
"FEATURE_6_DESC": "无需交换 IP 地址或密码。与您的朋友分享生成的邀请链接,让他们只需单击一下即可自动加入您的房间。",
"FEATURE_7_TITLE": "音量调节/音频压缩器",
"FEATURE_7_DESC": "不再需要调节音量旋钮。内置音频压缩器可增强安静的对话并抑制大声的爆炸声。适用于 Netflix、Prime Video、Disney+、YouTube 和任何 HTML5 视频。完全本地化——音频数据不会离开您的计算机。",
"FEATURE_8_TITLE": "开源(MIT 许可证)",
"FEATURE_8_DESC": "整个代码库可在 GitHub 上公开审核。没有黑匣子,没有专有 blob——从中继服务器到浏览器扩展 100% 透明。",
"COMP_TITLE": "KoalaSync 与 Teleparty",
"COMP_SUBTITLE": "了解为什么开源、无广告和隐私优先是一起观看的最佳方式。",
"COMP_COL_FEATURE": "特征",
"COMP_FEAT_1_NAME": "成本/付费专区",
"COMP_FEAT_1_DESC": "高级订阅、隐藏费用或锁定功能。",
"COMP_FEAT_1_KOALA": "100% 免费",
"COMP_FEAT_1_TELE": "付费等级/高级锁",
"COMP_FEAT_2_NAME": "许可证/代码可审核",
"COMP_FEAT_2_DESC": "源代码是否开放并可自由审核。",
"COMP_FEAT_2_KOALA": "开源(MIT",
"COMP_FEAT_2_TELE": "所有权",
"COMP_FEAT_3_NAME": "自托管",
"COMP_FEAT_3_DESC": "能够部署您自己的私有中继服务器。",
"COMP_FEAT_3_KOALA": "是(Docker-就绪)",
"COMP_FEAT_3_TELE": "不",
"COMP_FEAT_4_NAME": "隐私和追踪",
"COMP_FEAT_4_DESC": "注册要求、跟踪 cookie 和分析。",
"COMP_FEAT_4_KOALA": "零持久性(仅限 RAM",
"COMP_FEAT_4_TELE": "谷歌分析和 Cookie",
"COMP_FEAT_5_NAME": "站点兼容性",
"COMP_FEAT_5_DESC": "支持的网站和播放器兼容性。",
"COMP_FEAT_5_KOALA": "几乎任何 HTML5 视频",
"COMP_FEAT_5_TELE": "仅支持的网站",
"COMP_FEAT_6_NAME": "本地化/翻译",
"COMP_FEAT_6_DESC": "可用的界面语言和翻译支持。",
"COMP_FEAT_6_KOALA": "15 种语言(并且还在不断增加)",
"COMP_FEAT_6_TELE": "仅英语",
"COMP_FEAT_7_NAME": "音频压缩器/音量调节",
"COMP_FEAT_7_DESC": "内置音频标准化,以平衡安静的对话和响亮的效果",
"COMP_FEAT_7_KOALA": "有(4 个预设,干/湿控制)",
"COMP_FEAT_7_TELE": "不",
"COMP_FOOTNOTE_1": "比较状态:2026 年 5 月。",
"COMP_FOOTNOTE_2": "官方 Teleparty Premium 定价和支持的网络详细信息:",
"COMP_FOOTNOTE_3": "Teleparty 隐私政策和跟踪数据收集:",
"COMP_FOOTNOTE_4": "适用于允许将脚本注入标准 HTML5 视频标签的网站。具有高度严格的内容安全策略 (CSP)、DRM 复制保护或严重混淆的播放器包装器(如复杂的影子 DOM)的网站可能会限制自动控制或注入。",
"STEPS_TITLE": "入门",
"STEP_1_TITLE": "安装扩展",
"STEP_1_DESC": "从 Chrome Web Store、Firefox 附加组件将 KoalaSync 添加到您的浏览器,或从 GitHub 下载最新的开发人员 ZIP。",
"STEP_1_ILLUS_DESC": "隐私第一的视频同步器",
"STEP_1_ILLUS_DL_CHROME": "下载 Chrome",
"STEP_1_ILLUS_DL_FIREFOX": "下载 Firefox",
"STEP_1_ILLUS_ACTIVE": "分机激活",
"STEP_1_ILLUS_READY": "准备好一起观看吧!",
"STEP_2_TITLE": "创建房间",
"STEP_2_DESC": "打开扩展弹出窗口并单击“+ 创建新房间”。 KoalaSync 自动生成安全的 Room ID 和密码,加入它,并将邀请链接复制到剪贴板。",
"STEP_2_ILLUS_ROOM": "房间",
"STEP_2_ILLUS_SYNC": "同步",
"STEP_2_ILLUS_SETTINGS": "设置",
"STEP_2_ILLUS_CREATE": "+ 创建新房间",
"STEP_2_ILLUS_MANUAL": "手动连接/高级",
"STEP_2_ILLUS_COPIED": "邀请链接已复制!",
"STEP_3_TITLE": "分享与同步",
"STEP_3_DESC": "将邀请链接发送给您的朋友。他们加入后,选择您的视频选项卡并享受同步播放。",
"STEP_3_ILLUS_IN_SYNC": "同步",
"SELF_TITLE": "对于自托管者",
"SELF_SUBTITLE": "不信任我们的服务器?维护完整的数据主权。只需几分钟即可部署您自己的私有中继服务器。",
"SELF_MASCOT_ALT": "一只可爱的考拉坐在笔记本电脑前,部署 Docker 容器进行自托管",
"SELF_COPY_CODE": "复制代码",
"SELF_GITHUB_PACKAGES": "查看 GitHub 套餐上的所有图像标签",
"BOTTOM_TITLE": "不相信?你自己看看吧。",
"BOTTOM_SUBTITLE": "KoalaSync 100%开源、无广告、无跟踪。在 GitHub 上审核我们的代码库或直接安装浏览器扩展。",
"BOTTOM_MASCOT_ALT": "一只可爱的考拉在 GitHub 吉祥物 Octocat 旁边拿着 GitHub 存储库页面",
"FAQ_TITLE": "常见问题解答",
"FAQ_SUBTITLE": "关于完美同步地一起观看视频所需了解的一切。",
"FAQ_Q1": "我可以与朋友同步观看 Netflix、Emby 或 Jellyfin 吗?",
"FAQ_A1": "是的! KoalaSync 适用于 Netflix、YouTube、Twitch 以及任何具有 HTML5 视频播放器的网站 - 包括 Emby、Jellyfin 和自托管媒体服务器。安装扩展程序、创建房间、共享邀请链接,每个人都可以完美同步观看。",
"FAQ_Q2": "KoalaSync 是 Teleparty 的免费替代品吗?",
"FAQ_A2": "在 MIT 许可证下 100% 免费和开源。没有高级套餐、没有锁定功能、没有广告——一切都包含在内。与 Teleparty 不同,KoalaSync 的中继服务器仅是 RAM,需要零数据收集或用户帐户。",
"FAQ_Q3": "KoalaSync 是否可以与 Emby、Jellyfin 和自托管媒体服务器配合使用?",
"FAQ_A3": "是的。 KoalaSync 可检测任何标准 HTML5 视频元素,这意味着它可以与 Emby、Jellyfin、Plex 和任何自定义自托管流设置完美配合。您还可以通过 Docker 自托管 KoalaSync 中继服务器,以实现完整的数据主权。",
"FAQ_Q4": "哪些浏览器和平台支持KoalaSync",
"FAQ_A4": "Google Chrome、Mozilla Firefox、Microsoft Edge、Opera、Brave 以及所有基于 Chromium 的浏览器。直接从 Chrome Web Store 或 Firefox 附加组件安装 - 无需手动配置。",
"FAQ_Q5": "我所有的朋友都需要安装该扩展吗?",
"FAQ_A5": "是的,每个参与者都需要安装 KoalaSync - 但无需注册或创建帐户。只需单击邀请链接,安装扩展程序(如果尚未安装),您将在几秒钟内自动加入房间。",
"FAQ_Q6": "我的数据安全吗?如果我不信任官方服务器怎么办?",
"FAQ_A6": "完全安全。 KoalaSync 的中继服务器仅限 RAM — 所有会话数据在您离开房间时都会永久删除。没有数据库,没有日志记录,没有分析,没有帐户。如果您喜欢完全控制:您可以在几分钟内通过 Docker 自行托管您自己的私人中继服务器。整个代码库是开源的,并且可以在 GitHub 上进行审核。",
"FAQ_Q7": "我可以修复在浏览器中观看视频时安静的对话和大声的爆炸声吗?",
"FAQ_A7": "是的。 KoalaSync 2.2.0 包含一个内置音频压缩器,可减少视频安静部分和大声部分之间的差距。它通过 Web Audio API 在本地运行 — 任何音频数据都不会离开您的计算机。打开弹出窗口,创建一个房间,转到“设置”→“音频处理”,打开压缩器,然后选择一个预设。适用于 Netflix、Prime Video、Disney+、YouTube 和任何 HTML5 视频网站。",
"HOWTO_TITLE": "如何使用KoalaSync与好友同步视频",
"HOWTO_DESC": "与 YouTube、Netflix、Twitch 等平台上的朋友完美同步观看任何视频。无需注册,无需跟踪。",
"HOWTO_STEP_1_NAME": "安装浏览器扩展",
"HOWTO_STEP_1_TEXT": "从 Chrome Web Store 或 Firefox 附加组件将 KoalaSync 添加到您的浏览器。它适用于任何带有视频元素的网站。",
"HOWTO_STEP_2_NAME": "创建一个房间",
"HOWTO_STEP_2_TEXT": "打开扩展弹出窗口并单击“创建新房间”。邀请链接会自动复制到您的剪贴板。",
"HOWTO_STEP_3_NAME": "分享和同步",
"HOWTO_STEP_3_TEXT": "将邀请链接发送给您的朋友。选择一个视频选项卡并实时享受同步播放。",
"FOOTER_MIT": "根据 MIT 许可证开源。",
"FOOTER_RAM": "我们的服务器上不存储任何数据。纯RAM型继电器。",
"FOOTER_LEGAL": "法律声明",
"FOOTER_PRIVACY": "隐私政策",
"MOCK_01": "活动室",
"MOCK_02": "官方服务器",
"MOCK_03": "邀请链接",
"MOCK_04": "房间里的同伴",
"MOCK_05": "你",
"MOCK_06": "演奏",
"MOCK_07": "同行",
"MOCK_08": "留出空间",
"MOCK_09": "选择视频",
"MOCK_10": "遥控",
"MOCK_11": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"5 3 19 12 5 21 5 3\"/></svg> 玩",
"MOCK_12": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\" aria-hidden=\"true\"><rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/></svg> 暂停",
"MOCK_13": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"14\" height=\"14\" aria-hidden=\"true\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg> 同步",
"MOCK_14": "跳转到其他",
"MOCK_15": "上次活动状态",
"MOCK_16": "正在等待 1 个同伴 (KoalaPC)...",
"MOCK_17": "仍然跳过并播放",
"MOCK_18": "您的用户名",
"MOCK_19": "隐藏杂乱标签",
"MOCK_20": "自动同步下一集",
"MOCK_21": "自动复制邀请链接",
"MOCK_22": "浏览器通知",
"MOCK_23": "故障排除",
"MOCK_24": "再生Peer ID",
"MOCK_25": "如果您看到“重复身份”错误,请使用此选项。",
"MOCK_26": "连接状态",
"MOCK_27": "已连接",
"MOCK_28": "复制日志",
"MOCK_29": "视频调试信息",
"MOCK_30": "完整的行动历史",
"MOCK_31": "日志(最后 50 条)",
"MOCK_32": "清除",
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync 不隶属于 Netflix、Disney+、Amazon、YouTube、Twitch 或任何其他流媒体平台,也不受其认可或关联。所有商标均为其各自所有者的财产。",
"FOOTER_SUPPORT": "Support KoalaSync"
}
+8
View File
@@ -7,6 +7,11 @@
<link rel="preload" href="style.min.css" as="style">
<link rel="stylesheet" href="style.min.css">
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
<link rel="canonical" href="https://sync.koalastuff.net/privacy">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/privacy">
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/datenschutz">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/privacy">
<meta name="description" content="Privacy Policy of KoalaSync - We take data security and transparency seriously. Learn about our zero-tracking, zero-log, and zero-cookie approach.">
<meta name="robots" content="index, follow">
<script type="application/ld+json">
@@ -113,6 +118,9 @@
<p>
To enable cross-device synchronization, the KoalaSync browser extension temporarily captures data from the currently active video tab (e.g., tab title, media metadata like the video title, and playback state). This data is exclusively sent to other participants in your room for synchronization. We explicitly <strong>do not read, store, or transmit your general browsing history</strong>.
</p>
<p style="margin-top: 0.5rem;">
The extension only connects to the relay server while you are actively in a room. No persistent background connection is maintained — your IP address is not exposed to the server when you are not using the extension.
</p>
</section>
<section>
+17 -17
View File
@@ -3,31 +3,31 @@
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://sync.koalastuff.net/imprint</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/privacy</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/de/impressum</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -47,7 +47,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/de/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -67,7 +67,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/fr/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -87,7 +87,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/es/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -107,7 +107,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt-BR/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -127,7 +127,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ru/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -147,7 +147,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/it/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -167,7 +167,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pl/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -187,7 +187,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/tr/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -207,7 +207,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/nl/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -227,7 +227,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ja/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -247,7 +247,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/ko/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
@@ -267,7 +267,7 @@
</url>
<url>
<loc>https://sync.koalastuff.net/pt/</loc>
<lastmod>2026-06-09</lastmod>
<lastmod>2026-06-22</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
+136 -15
View File
@@ -61,6 +61,10 @@
border-radius: 6px;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
background-color: var(--bg);
@@ -354,7 +358,8 @@ nav {
}
.extension-mockup {
width: 320px;
width: 100%;
max-width: 320px;
height: 520px;
background: #0f172a;
border-radius: 20px;
@@ -1896,7 +1901,7 @@ footer {
@media (max-width: 768px) {
.hero-grid, .step {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
text-align: center;
}
.hero-text h1 {
@@ -1927,7 +1932,7 @@ footer {
margin-left: auto;
}
.hamburger {
display: none !important;
display: inline-flex !important;
}
nav .container {
padding: 0 0.5rem;
@@ -1962,6 +1967,21 @@ footer {
}
}
/* Extra-narrow phones: make sure logo, language picker and the menu button
never crowd each other in the header (kept comfortable down to ~320px). */
@media (max-width: 380px) {
nav .container {
padding: 0 0.4rem;
}
.nav-right {
gap: 0.35rem;
}
.logo-area {
font-size: 1rem;
gap: 0.4rem;
}
}
/* --- Language Toggle --- */
html.lang-de [lang="en"] {
display: none !important;
@@ -2007,12 +2027,14 @@ html:not(.lang-de) [lang="de"] {
}
.lang-select-container .chevron-icon {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
flex-shrink: 0;
pointer-events: none;
opacity: 0.6;
margin-left: -2px;
}
.lang-select-container:hover .chevron-icon {
@@ -2031,21 +2053,16 @@ html:not(.lang-de) [lang="de"] {
font-weight: 600;
color: currentColor;
cursor: pointer;
padding: 0 12px 0 0;
padding: 0 28px 0 0;
margin: 0;
width: auto;
max-width: 150px;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 110px;
overflow: hidden;
}
@supports (appearance: base-select) {
.lang-dropdown,
.lang-dropdown::picker(select) {
appearance: base-select;
}
.lang-dropdown::picker(select) {
background-color: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
@@ -2449,13 +2466,115 @@ html:not(.lang-de) [lang="de"] {
}
@media (max-width: 768px) {
.comparison-table th,
.comparison-table th,
.comparison-table td {
padding: 1rem;
font-size: 0.85rem;
}
}
/* On phones the 3-column table is turned into stacked cards so there is
no horizontal scrolling one card per feature, KoalaSync above Teleparty. */
@media (max-width: 600px) {
.comparison-table-wrapper {
overflow-x: visible;
background: transparent;
border: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
margin-top: 0;
}
.comparison-table {
display: block;
font-size: 0.95rem;
}
/* Visually hide the header row but keep it for screen readers. */
.comparison-table thead {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.comparison-table tbody,
.comparison-table tr,
.comparison-table td {
display: block;
width: 100%;
}
.comparison-table tbody tr {
background: rgba(30, 41, 59, 0.35);
border: 1px solid var(--glass-border);
border-radius: 14px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
margin-bottom: 1rem;
overflow: hidden;
}
.comparison-table tbody tr:last-child {
margin-bottom: 0;
}
.comparison-table td {
padding: 0.85rem 1.1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.comparison-table tbody tr td:last-child {
border-bottom: none;
}
/* Feature name acts as the card header. */
.comparison-table td.feat-name {
background: rgba(15, 23, 42, 0.55);
padding: 0.9rem 1.1rem;
}
/* Room is back on the card, so show the description again. */
.feat-desc {
display: none;
display: block;
margin-top: 0.15rem;
}
/* Label each value cell with the product it belongs to. */
.comparison-table td.check,
.comparison-table td.cross {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.comparison-table td.check::before,
.comparison-table td.cross::before {
flex: 0 0 5.5rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
opacity: 0.85;
}
.comparison-table td.check::before {
content: "KoalaSync";
}
.comparison-table td.cross::before {
content: "Teleparty";
}
/* Keep the KoalaSync row subtly highlighted, like on desktop. */
.comparison-table td.check.highlight {
background: rgba(99, 102, 241, 0.08);
}
}
@@ -2748,7 +2867,9 @@ html:not(.lang-de) [lang="de"] {
}
.feature-card,
.use-case-card,
.compat-logo {
.compat-logo,
section[id],
header[id] {
scroll-margin-top: 100px;
}
+20 -5
View File
@@ -25,6 +25,8 @@
<link rel="alternate" hreflang="nl" href="https://sync.koalastuff.net/nl/">
<link rel="alternate" hreflang="ja" href="https://sync.koalastuff.net/ja/">
<link rel="alternate" hreflang="ko" href="https://sync.koalastuff.net/ko/">
<link rel="alternate" hreflang="zh" href="https://sync.koalastuff.net/zh/">
<link rel="alternate" hreflang="uk" href="https://sync.koalastuff.net/uk/">
<link rel="alternate" hreflang="pt" href="https://sync.koalastuff.net/pt/">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/">
<meta name="robots" content="index, follow">
@@ -48,6 +50,8 @@
<meta property="og:locale:alternate" content="nl_NL">
<meta property="og:locale:alternate" content="ja_JP">
<meta property="og:locale:alternate" content="ko_KR">
<meta property="og:locale:alternate" content="zh_CN">
<meta property="og:locale:alternate" content="uk_UA">
<meta property="og:locale:alternate" content="pt_PT">
<meta property="og:site_name" content="KoalaSync">
@@ -85,7 +89,7 @@
"@type": "WebSite",
"name": "KoalaSync",
"url": "https://sync.koalastuff.net/",
"inLanguage": ["en", "de", "fr", "es", "pt-BR", "ru", "it", "pl", "tr", "nl", "ja", "ko", "pt"]
"inLanguage": ["en", "de", "fr", "es", "it", "nl", "pl", "pt", "pt-BR", "tr", "ru", "ja", "ko", "zh", "uk"]
}
</script>
<script type="application/ld+json" id="schema-software">
@@ -101,8 +105,8 @@
"price": "0",
"priceCurrency": "EUR"
},
"description": "Watch Netflix, YouTube, Twitch, Jellyfin, Emby and almost any HTML5 video in perfect sync with friends. Open source, privacy-first, free.",
"softwareVersion": "2.2.3",
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "2.4.4",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
@@ -183,6 +187,13 @@
]
}
</script>
<noscript>
<!-- Without JS the scroll-reveal observer never runs, so make all
reveal-animated content visible by default. -->
<style>
[data-reveal] { opacity: 1 !important; transform: none !important; }
</style>
</noscript>
</head>
<body>
@@ -224,6 +235,8 @@
<a href="https://sync.koalastuff.net/nl/" hreflang="nl">Nederlands</a>
<a href="https://sync.koalastuff.net/ja/" hreflang="ja">日本語</a>
<a href="https://sync.koalastuff.net/ko/" hreflang="ko">한국어</a>
<a href="https://sync.koalastuff.net/zh/" hreflang="zh">中文</a>
<a href="https://sync.koalastuff.net/uk/" hreflang="uk">Українська</a>
<a href="https://sync.koalastuff.net/pt/" hreflang="pt">Português (Portugal)</a>
</nav>
</div>
@@ -242,6 +255,8 @@
<option value="nl" {{SELECTED_NL}}>🇳🇱 Nederlands</option>
<option value="ja" {{SELECTED_JA}}>🇯🇵 日本語</option>
<option value="ko" {{SELECTED_KO}}>🇰🇷 한국어</option>
<option value="zh" {{SELECTED_ZH}}>🇨🇳 中文</option>
<option value="uk" {{SELECTED_UK}}>🇺🇦 Українська</option>
<option value="pt" {{SELECTED_PT}}>🇵🇹 Português (Portugal)</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
@@ -1250,9 +1265,9 @@
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}" style="color: var(--text-muted); text-decoration: none;"><span>{{FOOTER_LEGAL}}</span></a>
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}" style="color: var(--text-muted); text-decoration: none;"><span>{{FOOTER_PRIVACY}}</span></a>
<a href="https://ko-fi.com/koaladev" target="_blank" rel="noopener" title="{{FOOTER_SUPPORT}}" style="color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; background: #ff5e5b; border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; padding: 3px 9px; font-size: 0.72rem; font-weight: 700; line-height: 1;">
<a href="https://support.koalastuff.net" target="_blank" rel="noopener" title="{{FOOTER_SUPPORT}}" style="color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; background: #ff5e5b; border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; padding: 3px 9px; font-size: 0.72rem; font-weight: 700; line-height: 1;">
<span aria-hidden="true"></span>
<span>Ko-Fi</span>
<span>{{FOOTER_SUPPORT}}</span>
</a>
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2.2.4",
"date": "2026-06-10T12:00:00Z"
"version": "2.4.4",
"date": "2026-06-22T22:46:03Z"
}