Compare commits

...

151 Commits

Author SHA1 Message Date
Timo 92fb8e2d00 Roll back network latency compensation to maximize simplicity and clock skew safety 2026-05-30 00:06:52 +02:00
Timo 43cde9bbef Optimize network latency compensation cap to protect against system clock skew 2026-05-30 00:03:56 +02:00
Timo 56955027f9 Fix critical sync lockouts, zombie disconnects, play-seek debounces, active lobbies, SW keep-alives, branding matches and ESLint warnings 2026-05-30 00:02:19 +02:00
GitHub Action 5ef059a94f chore(release): update versions to v1.9.0 [skip ci] 2026-05-28 02:55:04 +00:00
Koala 98b4fc5fb4 release: v1.9.0 — command sequencing, episode-aware sync, echo suppression
- Add per-peer monotonically increasing seq numbers (localSeq + lastSeqBySender)
- Server relays seq for stale command detection (backward compatible)
- Replace expectedEvents (1500ms timeout) with _suppressTimers (per-type, 300ms)
- Fix FORCE_SYNC_ACK missing seq (stale-ACK guard)
- Fix episode lobby readyPeers asymmetry (initiator now included)
- Add extractEpisodeId() parsing S01E01, Season 1 Ep 1, Folge 5, Ep. 3, #42
- Add isDifferentEpisode() guard blocking cross-episode sync commands
- Add sameEpisode() for format-tolerant lobby title matching
- Guard controlled via autoSyncNextEpisode setting
- Reduce reactive lock 1000ms → 300ms
- Fix routeToContent unbounded retry (max 3 attempts)
- Fix server bcrypt.hash failure crashing join flow
- Fix sameEpisode(null, title) returning true
- Fix forceSyncTimeout leak on rapid force sync clicks
- Persist lastSeqBySender across service worker restarts
- Bump version: 1.8.10 → 1.9.0
2026-05-28 04:54:54 +02:00
Koala 762d6425be fix: audit corrections for sameEpisode, CMD_ACK, lastSeqBySender, forceSyncTimeout
- sameEpisode: return false when one title is null (prevent false lobby completions)
- Episode guard: skip CMD_ACK for FORCE_SYNC_EXECUTE on mismatch (H1)
- Persist lastSeqBySender in chrome.storage.session (M3, survives SW restart)
- Clear forceSyncTimeout before overwriting (H3, pre-existing timer leak)
2026-05-28 04:45:52 +02:00
Koala 1fba2fb69c perf: add command sequence numbers, episode-aware sync guard, fix echo suppression
- Add monotonically increasing seq per peer (localSeq + lastSeqBySender)
- Server relays seq field for stale command detection (backward compatible)
- Replace expectedEvents (1500ms timeout) with _suppressTimers (per-type, 300ms)
- Fix FORCE_SYNC_ACK missing seq (stale-ACK could trigger premature exec)
- Fix episode lobby readyPeers asymmetry (initiator never broadcasted readiness)
- Add extractEpisodeId() parsing S01E01/Season 1 Episode 1/Folge 5 patterns
- Add isDifferentEpisode() guard blocking sync commands across different episodes
- add sameEpisode() for format-tolerant lobby title matching
- Guard controlled by autoSyncNextEpisode setting with disable hint in warning
- Reduce reactive lock from 1000ms to 300ms (seq numbers handle ordering)
2026-05-28 04:30:32 +02:00
Koala eca259281a feat: expand emoji/name system with comprehensive animal mapping
- Create shared/names.js as single source of truth for emoji maps, noun
  lists, adjectives, and username generation (215 animal emoji entries)
- Remove duplicated inline lists from popup.js and background.js; both
  now import getAvatarForName / generateUsername from shared/names.js
- Add names.js to the build script sync list so it stays DRY across
  extension builds
- Fix missing cat emoji: CoolCat now correctly resolves to 🐱
- Sort emoji map keys by length at lookup time to prevent substring
  false-matches (e.g. 'caterpillar' before 'cat')
2026-05-28 03:35:17 +02:00
GitHub Action afd28be2e6 chore(release): update versions to v1.8.10 [skip ci] 2026-05-26 15:42:24 +00:00
Koala cc0265c836 v1.8.10: fix description length, solo peer detection, tab-close peer notify, lastHeartbeat guard, time interpolation stale guard 2026-05-26 17:42:11 +02:00
GitHub Action 6ebff9ab4c chore(release): update versions to v1.8.9 [skip ci] 2026-05-26 01:07:16 +00:00
Koala 35e779c1ff move version link outside h1 to avoid inherited heading styles 2026-05-26 03:00:29 +02:00
Koala 09f0e04891 add clickable version with GitHub icon to popup header 2026-05-26 02:58:36 +02:00
Koala 9eff53ba46 chore: remove CSP-blocked inline handlers from landing page 2026-05-26 02:47:45 +02:00
GitHub Action bde2f7ea55 chore(release): update versions to v1.8.8 [skip ci] 2026-05-26 00:34:04 +00:00
Koala 901861269a fix: Firefox invite — cloneInto() for CustomEvent detail (XrayWrapper) 2026-05-26 02:33:54 +02:00
GitHub Action ba91e2744c chore(release): update versions to v1.8.7 [skip ci] 2026-05-26 00:06:39 +00:00
Koala b7a44024ab fix: suppress ghost seek events on tab re-focus (Firefox tab throttling) 2026-05-26 02:06:23 +02:00
Koala 3c49bfe54c feat: SEO — add Emby/Jellyfin to descriptions, optimize meta tags, fix APP_VERSION 2026-05-26 01:51:34 +02:00
GitHub Action d2ea7c7423 chore(release): update versions to v1.8.6 [skip ci] 2026-05-25 23:07:55 +00:00
Koala db11812bd6 fix: suppress seek events when solo, add moz-extension CORS, add server logging 2026-05-26 01:06:04 +02:00
GitHub Action 6db8fdbf75 chore(release): update versions to v1.8.5 [skip ci] 2026-05-25 21:56:58 +00:00
Koala d23c37f87f chore(release): v1.8.5 2026-05-25 23:56:50 +02:00
Koala acd428d4f7 fix: audit fixes — auto-match title corruption, duplicate HTML attrs, stale ℹ️ icons, missing tooltip, remove fix_ui.js 2026-05-25 23:56:14 +02:00
Koala c0a6f0adc2 ui: clipboard emoji for invite button, movie emoji for audible tab indicator 2026-05-25 23:56:14 +02:00
GitHub Action 42b73bb97f chore(release): update versions to v1.8.3 [skip ci] 2026-05-25 21:43:51 +00:00
Koala dc36bfdded chore(release): v1.8.3 2026-05-25 23:43:41 +02:00
Koala 06db850387 fix(ui): add comprehensive tooltips to all labels, buttons and inputs 2026-05-25 23:43:09 +02:00
Koala 0de92b5b61 fix(ui): tooltips, full emoji map, and onboarding layout 2026-05-25 23:37:21 +02:00
GitHub Action 8ff8e7beb6 chore(release): update versions to v1.8.1 [skip ci] 2026-05-25 21:32:16 +00:00
Koala 8317099072 chore(release): prep for v1.8.1 2026-05-25 23:32:07 +02:00
Koala 04c63dcf68 fix(ui): add missing avatar emoji in episode lobby UI 2026-05-25 23:30:26 +02:00
Koala fd23ccc23a feat(ui): v1.8.1 visual upgrades (emojis, animations, audio indicator) 2026-05-25 23:29:06 +02:00
Koala 4d5caeda9e style(ui): redesign sync button layout and rename to SYNC 2026-05-25 23:20:37 +02:00
Koala f1f41e5cac fix(ui): apply tooltip to full label text for settings 2026-05-25 23:19:23 +02:00
GitHub Action 473eacda22 chore(release): update versions to v1.8.0 [skip ci] 2026-05-25 21:09:07 +00:00
Koala 42029f86bf chore(release): prep for v1.8.0 2026-05-25 23:08:55 +02:00
Koala 4c4a2638d7 docs: rearrange README to prioritize user quick start 2026-05-25 23:06:58 +02:00
Koala 5b57970c4c docs: use for-the-badge style for download links 2026-05-25 23:05:22 +02:00
Koala 61492f953b docs: Update README quick start for users 2026-05-25 23:04:14 +02:00
Koala da6a1cc643 Server updates 2026-05-25 22:59:54 +02:00
Koala 62fdffa5ee UX Improvements: Onboarding, UI Feedback, Auto-Copy Invite 2026-05-25 22:59:54 +02:00
GitHub Action fb13978c9d chore(release): update versions to v1.7.5 [skip ci] 2026-05-25 11:26:16 +00:00
Koala 82c09a5328 fix(popup): use hostname-aware domain matching to prevent false positives
The noise filter used naive String.includes() on the full URL, causing
blacklist entry 'x.com' to match netflix.com (since 'netflix.com'
contains 'x.com' as substring). This made Netflix tabs disappear when
the filter was enabled.

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

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

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

- Create lock promise before bcrypt.hash async boundary
- Release in finally to cover all exit paths (success, MAX_ROOMS, error)
- Concurrent waiters now correctly await existing room creation
2026-05-25 12:23:16 +02:00
Koala 6f08a9d7c4 fix: restore auth failure records retention line, update to 15 minutes 2026-05-25 11:57:37 +02:00
Koala 440ed2db47 docs: update PRIVACY.md and ARCHITECTURE.md for v1.7.0 changes 2026-05-25 11:56:46 +02:00
Koala ed24a4c263 fix: commandSenderMap race - embed senderId in SERVER_COMMAND/CMD_ACK instead of global Map 2026-05-25 11:52:05 +02:00
Koala 284b82a910 fix: v1.7.0 - critical bug fixes, race conditions, memory leaks, null guards, server hardening 2026-05-25 11:48:54 +02:00
Koala f59d30569e perf: optimize reconnect flow - aggressive backoff (500ms→5s), reconnect UI status, 30s keepAlive 2026-05-25 10:29:19 +02:00
Koala f23c7eb3c8 docs: remove unapproved features from roadmap 2026-05-25 10:14:44 +02:00
Koala 9a4dd41555 Re-add Netflix to supported platforms — works via HTML5 video tag 2026-05-25 10:12:11 +02:00
Koala 2067f76ced improve onboarding copy - add welcome step, friendlier text, 5 steps 2026-05-25 10:08:02 +02:00
Koala 3b65af1bbb feat: sprint 2 - empty states, onboarding tour, dev tab optimization, expanded usernames
- Add renderEmpty() helper with icons and hints for peers/history/logs/rooms
- Implement onboarding tour (4 steps) with overlay, dots, skip/next
- Dev tab logs only poll when tab is visible (isDevTabVisible flag)
- Expand username generation from 12/12 to 30/30 adjective-noun pairs
- Update ROADMAP.md to remove implemented features
2026-05-25 10:05:47 +02:00
Koala bb316340a7 Add remaining koalastuff.net subdomains to blacklist 2026-05-25 10:02:28 +02:00
Koala a3af397fe9 Add koalastuff.net and snippets.koalastuff.net to blacklist 2026-05-25 10:01:27 +02:00
Koala 0ac2b49d89 Add sync.koalastuff.net to domain blacklist 2026-05-25 09:57:31 +02:00
Koala c9f93dc4ba fix: add null guards to prevent runtime crashes
- popup.js: guard peers/state.acks in updateLastActionUI()
- popup.js: guard state.duration in refreshDebugInfo()
- popup.js: guard elements.lobbyPeerStatus in updateLobbyUI()
- background.js: use Array.isArray() for currentRoom.peers.length checks
2026-05-25 09:57:01 +02:00
Koala 4909a86a13 feat: implement sprint 1 quick wins - toast system, notifications, UX polish
- Add central toast notification system (popup.html, popup.js)
- Add browser notifications toggle (opt-in) with event toasts
- Fix interpolation memory leak (unload listener)
- Add /health endpoint with IP-based rate limiting (server)
- Improve tab sorting (current tab first, matches, alphabetical)
- Add copy-to-clipboard visual feedback with toast
- Show targetTime in last action card for seek/force sync
- Add explicit video cleanup when element removed (content.js)
- Update ROADMAP.md to remove implemented features
2026-05-25 09:53:25 +02:00
Koala d66c68be5d Use normal font size for italic description 2026-05-25 09:52:14 +02:00
Koala 23d4b7068e Center and shrink description text in README 2026-05-25 09:50:48 +02:00
Koala f40d4e8de4 Center heading and italicize description in README 2026-05-25 09:50:01 +02:00
Koala e996275c2a Revert "Restore centered banner design with italic tagline and honest copy"
This reverts commit dd37045afc.
2026-05-25 09:49:35 +02:00
Koala dd37045afc Restore centered banner design with italic tagline and honest copy 2026-05-25 09:46:54 +02:00
Koala 92bec29215 Remove exaggerated marketing claims and fix technical inaccuracies across docs and website 2026-05-25 09:37:13 +02:00
Koala 552afac26a docs: add comprehensive roadmap with implementation plans 2026-05-25 09:36:42 +02:00
Koala 3c671bcfab Expand blacklist with more mail, search, and social domains 2026-05-25 09:35:03 +02:00
Koala 18ed8953db Add more domains to tab blacklist 2026-05-25 09:24:49 +02:00
Koala 967fe8872e Split Chrome/Firefox badges and link to web stores 2026-05-25 08:44:31 +02:00
GitHub Action 0e93e31bd0 chore(release): update versions to v1.6.1 [skip ci] 2026-05-25 00:49:48 +00:00
Timo 19ddfd1e21 Add ESLint setup, fix 6 lint errors, update AI_INIT.md with lint checks 2026-05-25 02:46:29 +02:00
Timo 4d85362020 Fix 5 frontend audit findings: FORCE_SYNC_ACK timeout, expectedTimeouts leak, undefined var, unreachable error handler, Smart Match perf 2026-05-25 02:35:02 +02:00
Timo 53c1b8eea3 feat(website): add Chrome Web Store link to download buttons 2026-05-25 02:30:15 +02:00
Timo 2b2aeeba00 fix(extension): compact Last Activity Status card with scroll and smaller elements 2026-05-25 02:28:33 +02:00
Timo 5067b8e541 fix(extension): exclude own tab from smart match and send immediate heartbeat after injection 2026-05-25 02:24:50 +02:00
Timo 8778403449 docs: add mandatory pre-session git pull instruction to AI_INIT.md 2026-05-25 02:17:50 +02:00
GitHub Action af87e34f5d chore(release): update versions to v1.6.0 [skip ci] 2026-05-25 00:15:24 +00:00
Timo 385602c194 fix(extension,server): resolve 12 audit findings (lobby cleanup, dedup race, protocol version, rate limiting, observer scope, and more) 2026-05-25 02:14:59 +02:00
Timo bf0fa55b9d feat: add ForceSync mode selector (Jump to Others / Jump to Me) 2026-05-25 02:14:59 +02:00
GitHub Action f063fd5f3d chore(release): update versions to v1.6.0 [skip ci] 2026-05-22 22:19:12 +00:00
Koala 0a555942f8 chore: release v1.6.0 and apply fixes 2026-05-23 00:19:02 +02:00
GitHub Action d3f680e313 chore(release): update versions to v1.5.4 [skip ci] 2026-05-18 19:39:54 +00:00
Timo e8203419a3 fix(extension): eliminate potential async race condition in slow reconnect by capturing attempt flag synchronously 2026-05-18 21:37:39 +02:00
Timo e3536ad1a7 refactor(extension): implement infinite, low-frequency background reconnect checks after initial 5-minute failure 2026-05-18 21:34:46 +02:00
Timo aa1382b2ad security(website): refactor inline scripts and click handlers to external files to achieve 100% strict CSP compliance 2026-05-18 21:21:38 +02:00
Timo 44ee3fab25 docs(website): add static asset caching and strict CSP template configurations in Caddyfile examples 2026-05-18 21:13:29 +02:00
Timo e9b55c72f0 style(website): replace emoji in Step 1 illustration with official brand logo 2026-05-18 20:52:19 +02:00
Timo a83cdf4b03 feat(website): link hero github button to releases and add packages version link under docker pane 2026-05-18 20:51:24 +02:00
Timo 3fb48ee822 feat(website): expand features grid from 4 to 6 symmetrical tiles including self-hosting and invites 2026-05-18 20:49:03 +02:00
Timo 2d20af7199 fix(website): add missing navbar home bilingual translation on join page 2026-05-18 20:43:56 +02:00
Timo 579677e11c feat(website): overhaul interactive extension mockup alignment and update server docker/caddy examples 2026-05-18 20:43:02 +02:00
Timo 05e1801653 feat(website): fully redesign website and invitation page with premium responsive layouts, inline SVG icons, and a sleek vector Globe language selector 2026-05-18 20:31:18 +02:00
Timo 6774b2bfb7 feat(website): fix Chrome icon, add dynamic Firefox targeting and update versioning guidelines 2026-05-18 20:15:10 +02:00
GitHub Action 108d015e9f chore(release): update versions to v1.5.3 [skip ci] 2026-05-18 18:10:54 +00:00
Timo e0b9e9ef27 perf(background): reduce reactive update lock duration to 1.0s 2026-05-18 20:10:34 +02:00
GitHub Action 641c9bfb24 chore(release): update versions to v1.5.2 [skip ci] 2026-05-18 18:09:45 +00:00
Timo cf993b4ef0 fix(background): reset reactive locks on execute force sync for instant play update 2026-05-18 20:09:20 +02:00
Timo 5b1d6d7ba4 docs: add mandatory node -c syntax check before committing/pushing 2026-05-18 19:45:01 +02:00
GitHub Action 9ed7c98933 chore(release): update versions to v1.5.1 [skip ci] 2026-05-18 17:44:37 +00:00
Timo 68b0f3306c fix(background): resolve missing closing brace syntax error in alarms onAlarm listener 2026-05-18 19:44:12 +02:00
GitHub Action a71cdc3e53 chore(release): update versions to v1.5.0 [skip ci] 2026-05-18 17:41:13 +00:00
Timo 366d157d80 feat: implement Strategy C (zero-latency hybrid synchronization) and fix room switching duplicate matches 2026-05-18 19:40:46 +02:00
GitHub Action 2d231cb390 chore(release): update versions to v1.4.5 [skip ci] 2026-05-18 17:24:42 +00:00
Timo b11b3316af fix(firefox): add gecko data collection permissions and proper icon sizes 2026-05-18 19:24:19 +02:00
MacBook e337527e63 docs: add inline icon to README.md header 2026-05-18 15:17:09 +02:00
GitHub Action 882565a079 chore(release): update versions to v1.4.4 [skip ci] 2026-05-18 13:14:48 +00:00
MacBook 4653c455d5 feat(landing): implement dynamic version and release date display 2026-05-18 15:14:23 +02:00
Timo 800a6c09b7 UI: Overwrite website logo/favicon with new monochrome icon 2026-05-18 14:58:18 +02:00
Timo 751496ed48 UI: Update KoalaSync icon to monochrome version to match neon theme 2026-05-18 14:56:22 +02:00
Timo 734a004f23 UI: Add new icon to popup header next to title 2026-05-18 14:52:06 +02:00
MacBook 01ce3ec99e chore: migrate domains from shik3i.net to koalastuff.net 2026-05-16 13:08:53 +02:00
MacBook d9b26f9fb4 docs: link Caddyfile.example and finalize website documentation 2026-05-04 05:55:46 +02:00
MacBook 1e3bb94660 website: fix HTML issues, mobile nav, i18n, and SEO
Bug Fixes:
- index.html: remove duplicate <title> tags (invalid HTML), use single
  title + JS swap; fix og:image relative→absolute URL (logo.png)
- join.html: fix hardcoded lang=de, add language detection IIFE,
  add lang toggle to nav, make badge/title/desc/actions bilingual,
  add missing </head> closing tag, add noindex meta
- app.js: join status handler was German-only; now fully bilingual
  (Success/Error titles, countdown, close/retry button labels)

Improvements:
- Add Firefox 'Add to Firefox' button to hero CTA (placeholder href=#)
- impressum.html / datenschutz.html: add noindex meta to keep legal
  pages out of search results
- robots.txt: add Sitemap directive for SEO crawlability
- sitemap.xml: new file listing main page + legal pages with priorities
- style.css: mobile nav hamburger menu (☰) replaces display:none
  blackout; nav-links drop down on mobile with backdrop blur
- style.css: cta-group flex-wrap for narrow screens
- Hamburger button added to all 4 page navs
2026-05-04 05:49:16 +02:00
MacBook 90882f91ef docs: clarify ws:// vs wss:// for self-hosted servers 2026-05-04 05:42:31 +02:00
MacBook 5157428e74 docs: comprehensive repository polish + step-by-step user guide
Documentation Rewrites:
- AI_INIT.md: fix duplicate section numbers, add file responsibility map,
  fix stale manual mirror instruction, add room ID constraint
- PRIVACY.md: add TL;DR statement, data retention table, explicit
  <all_urls> justification, self-hosted instance disclaimer
- CONTRIBUTING.md: add local testing guide, version warning, room ID
  constraint, bug report requirements
- shared/README.md: complete event table (all 15 events), fix stale
  manifest.json reference
- docs/SYNC_GUIDE.md: Chrome→Browser, add README.md to sync list,
  drop stale RC5 reference
- server/README.md: sync env defaults with .env.example (1000/50)

New Documentation:
- docs/HOW_IT_WORKS.md: 10-step walkthrough covering room creation,
  invitation bridge flow, synchronized playback, force sync protocol,
  heartbeat system, and episode auto-sync. Includes exact data payloads.

Infrastructure Cleanup:
- docker-compose.yml: remove deprecated version key
- .dockerignore: remove dead .bat/.sh patterns
- README.md: add self-hosting extension config tip, link HOW_IT_WORKS
2026-05-04 05:41:43 +02:00
MacBook 47ca7563b7 ci: auto-inject version from git tag + unify shared/ mirroring
- CI release workflow now extracts version from tag (v1.4.0 → 1.4.0)
  and injects it into manifest.base.json, shared/constants.js, and
  package.json before building — the tag is the single source of truth
- Build script now copies README.md alongside constants.js and
  blacklist.js from /shared → /extension/shared/ (full mirror)
- .gitignore updated: extension/shared/ is fully generated by build
- AI_INIT.md: simplified release workflow, fixed Chrome-only reference
2026-05-04 05:27:07 +02:00
MacBook f7829bbebb security: harden server relay + documentation audit
Server Security (S-1 through S-8):
- S-1: Type-check and clamp peerId, protocolVersion, password
- S-2: Validate numeric/boolean/enum fields in relay peerData
- S-3: Construct explicit relay payload (stop spreading raw data)
- S-4: Type-check targetId and actionTimestamp in EVENT_ACK
- S-5: Restrict room IDs to [a-zA-Z0-9-] only
- S-7: Add eventCounts periodic cleanup alongside connectionCounts
- S-8: Guard version parsing against NaN bypass

Documentation (P-1, R-1 through R-6):
- P-1: Fix PRIVACY.md typo, document all in-memory data maps
- R-1/R-5: Fix stale sync-constants.bat references in shared/
- R-2: Fix stale lastTargetState ref in ARCHITECTURE.md
- R-3: Extension README title reflects cross-browser support
- R-6: Document content injection markers in scripts/README.md
2026-05-04 05:19:18 +02:00
MacBook 6093da4dc6 feat: add docker-compose example and update GHCR deployment links 2026-05-04 05:12:22 +02:00
MacBook 583e15745f docs: improve transparency in privacy policy and readme 2026-05-04 04:55:04 +02:00
MacBook bd8c7edc3a docs: clean up README marketing language and remove banner 2026-05-04 04:51:29 +02:00
MacBook bd4c53f9c7 docs: overhaul root README with premium branding and badges 2026-05-04 04:48:51 +02:00
MacBook 5440d136fe docs: complete audit of all READMEs for consistency 2026-05-04 04:46:27 +02:00
MacBook c9ec6ce3e0 docs: final polish and refined protocol automation 2026-05-04 04:45:02 +02:00
MacBook 652f1cef4f refactor: automate protocol sync and reorganize repo for store readiness 2026-05-04 04:43:30 +02:00
MacBook 0f1f8bde1b docs: clean up obsolete scripts and update documentation for store readiness 2026-05-04 04:37:59 +02:00
MacBook fa4e4039b3 fix: bump APP_VERSION in shared/constants.js to 1.3.1 2026-05-02 02:09:23 +02:00
MacBook cb466d3865 chore: bump version to 1.3.1
- update manifest.base.json, package.json and index.html to v1.3.1

- add SECURITY.md file
2026-05-02 02:07:22 +02:00
MacBook 54be9f9a39 feat: SEO and localization improvements
- Add language toggle and German translation to website

- Optimize manifest description

- Add robots.txt

- Add open source transparency clause to privacy policy
2026-05-02 02:00:06 +02:00
MacBook 47a9c08f48 docs: enforce strict version bumping in AI release workflow 2026-05-02 00:37:09 +02:00
MacBook b0bcab77e3 docs: restructure README for end-users and developers, add browser support, add badges 2026-05-01 06:18:27 +02:00
MacBook 6624bcc1ca docs: remove hardcoded version string and link to releases tab 2026-05-01 06:14:11 +02:00
MacBook ca391ca83b docs: add AI guardrails for networking, routing, and identity 2026-05-01 06:11:50 +02:00
MacBook 1438a4d41f docs: add release tag 'v' prefix requirement to AI_INIT.md 2026-05-01 06:10:25 +02:00
MacBook c9cf7c49dc Fix logic flaws and update documentation 2026-05-01 06:06:46 +02:00
MacBook bcbd46d658 fix: make shared file sync fail-fast in build script 2026-05-01 05:41:07 +02:00
MacBook 4d489ec992 chore: implement cross-browser build pipeline for extension
- Extract manifest.json to manifest.base.json
- Add Node.js build script to compile Chrome and Firefox artifacts
- Remove legacy sync-constants bat/sh scripts
- Update GitHub Actions workflow to use new build pipeline
2026-05-01 05:37:47 +02:00
56 changed files with 7204 additions and 959 deletions
-2
View File
@@ -7,7 +7,5 @@ extension/
website/
scripts/
*.md
*.bat
*.sh
.env
server/.env
+43 -7
View File
@@ -52,19 +52,55 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Sync Protocol Constants
run: |
chmod +x ./scripts/sync-constants.sh
./scripts/sync-constants.sh
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Create Extension Zip
- name: Inject version into source files
run: |
zip -r koala-sync-extension.zip extension/ -x "*.DS_Store*"
VERSION=${{ steps.version.outputs.VERSION }}
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "Injecting version $VERSION from tag $GITHUB_REF_NAME..."
# 1. extension/manifest.base.json
jq --arg v "$VERSION" '.version = $v' extension/manifest.base.json > tmp.json && mv tmp.json extension/manifest.base.json
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
echo " ✓ shared/constants.js -> $VERSION"
# 3. package.json
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
echo " ✓ package.json -> $VERSION"
# 4. website/version.json
jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json
echo " ✓ website/version.json -> version $VERSION, date $DATE"
echo "Version injection complete."
- name: Commit and push version updates back to main
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add extension/manifest.base.json shared/constants.js package.json website/version.json
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
git push origin HEAD:main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Extensions
run: |
npm install
npm run build:extension
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: koala-sync-extension.zip
files: |
dist/koalasync-chrome.zip
dist/koalasync-firefox.zip
name: Release ${{ github.ref_name }}
generate_release_notes: true
draft: false
+1 -2
View File
@@ -38,8 +38,7 @@ coverage/
# KoalaSync Specific
# We ignore the synced files in the extension folder to ensure
# the root 'shared/' remains the Single Source of Truth.
extension/shared/*
!extension/shared/README.md
extension/shared/
# Temporary scratch files
scratch/
+60 -22
View File
@@ -15,31 +15,41 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
- **Identity**: Users are identified by a unique hex `peerId` combined with a customizable `username`.
## 2. Repository Structure
- `extension/`: Chrome Extension (Manifest V3). Contains background service worker, content scripts, and popup UI.
- `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).
- `shared/`: **Single Source of Truth** for protocol constants and event names.
- `scripts/`: Utility scripts (e.g., `sync-constants.sh`).
- `scripts/`: Development utilities (e.g., `build-extension.js`).
- `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 `.\scripts\sync-constants.bat` or `./scripts/sync-constants.sh`.
> **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`.
> - **Extension Modules** (`background.js`, `popup.js`) import directly from `./shared/constants.js`.
> - **Content Scripts** (`content.js`) use a **manual synchronous mirror** to prevent race conditions during page load. Always verify parity after sync.
> - **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.
## 3. Mandatory Reading
Before touching any code, you MUST read the following documents in order:
1. [ARCHITECTURE.md](ARCHITECTURE.md) Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
1. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
2. [extension/README.md](extension/README.md) Extension components, tab structure, and loading process.
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) Protocol constants and synchronization requirements.
3. [docs/SYNC_GUIDE.md](docs/SYNC_GUIDE.md) Protocol constants and synchronization requirements.
## 4. The "Vanilla JS Mirror" Pattern
To avoid boot-time race conditions in Manifest V3 without a bundler, the following architectural trade-off is enforced:
- **Synchronous Execution**: `content.js` MUST execute synchronously to catch early media events.
- **Manual Mirroring**: `content.js` maintains a manual mirror of the `EVENTS` constants from `shared/constants.js`.
- **Maintenance**: Developers must ensure that any changes to `shared/constants.js` are manually reflected in `content.js` after running the sync scripts.
- **Automated Injection**: The build script (`node scripts/build-extension.js`) automatically injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` using marker-based replacement (see `scripts/README.md` for marker details).
- **Maintenance**: After modifying `shared/constants.js`, simply run the build script. No manual mirroring is required.
## 5. Design Guidelines
## 5. File Responsibility Map
| File | Responsibility |
|:-----|:---------------|
| `background.js` | WebSocket client, state orchestrator, event router, session persistence |
| `content.js` | Video element detection, media control, event origin detection (loop prevention) |
| `popup.js` | UI rendering, user input handling, peer display, invitation link generation |
| `bridge.js` | Landing page ↔ extension communication for invitation join flow |
| `server/index.js` | Room management, message relay, rate limiting, authentication, peer lifecycle |
## 6. Design Guidelines
The popup UI follows a strict design system. Do not modify these variables or the layout structure without explicit approval.
- **Font**: System font stack. **MANDATORY**: No external CDNs or Google Fonts to ensure 100% privacy.
- **Popup Width**: Fixed at `320px`.
@@ -53,11 +63,12 @@ The popup UI follows a strict design system. Do not modify these variables or th
| `--success` | `#22c55e` | Success states / Online dot |
| `--error` | `#ef4444` | Errors / Offline dot |
## 5. Non-Negotiables (Core Logic)
## 7. Non-Negotiables (Core Logic)
The following features are critical and must not be removed or fundamentally altered:
- **Two-Phase Force Sync**: The `Prepare``ACK``Execute` flow ensures all peers are buffered before playback resumes.
- **Episode Auto-Sync**: Ensures series binges stay perfectly synced. A lobby initiates during title transitions, freezing peers until everyone is ready.
- **Dual Heartbeat**:
- **Background Heartbeat (30s)**: Ensures session persistence even without a video element.
- **Background Heartbeat (1m)**: Ensures session persistence even without a video element.
- **Content Heartbeat (15s)**: Transmits current video metadata (time, title).
- **Dead Peer Pruning**: Server "Reaper" disconnects peers after 5 minutes of total silence (no heartbeats or events).
- **Deduplication**: Server kills old sockets if a user re-joins with the same `peerId` to prevent ghosts.
@@ -66,33 +77,60 @@ The following features are critical and must not be removed or fundamentally alt
- **SW Keep-alive**: Use of `chrome.alarms` to prevent the Manifest V3 Service Worker from suspending.
- **Diagnostics**: The "Dev" tab provides real-time access to the underlying `<video>` state for troubleshooting.
- **Persistence**: `peerId` and `username` must be stored to remain stable across sessions.
- **Room ID Format**: Room IDs are restricted to `[a-zA-Z0-9-]` only (alphanumeric + hyphens). This is enforced server-side.
## 6. Technical Constraints
## 8. Technical Constraints
- **No Bundler**: The extension uses plain ES Modules. Do not introduce build steps or npm packages into the `extension/` folder.
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol natives.
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol natively.
- **Server Transport**: Restricted to `websocket` only. Polling is disabled.
- **Docker Context**: The Docker build must run from the **Repo Root**.
- **Manifest Settings**: `run_at` must remain `document_idle`, and `all_frames` must remain `false`.
## 7. Security & Deployment
## 9. Security & Deployment
- **Tokens**: Security tokens are intentionally managed via `shared/constants.js` and server `.env`.
- **Environment**: `.env` is excluded via `.gitignore`. Only `.env.example` should be committed.
- **Revocation**: `MIN_VERSION` check on the server is used to deprecate old extension versions.
- **Invitation Links**: Correctly propagate server URLs, Room IDs, and Passwords via the URL hash to the bridge.
## 8. Common Workflows
## 10. Common Workflows
### ⚠️ Pre-Session Git Sync (MANDATORY)
Before starting any task, committing, or pushing, you **MUST** run `git pull --rebase` to ensure your local branch is up-to-date with `origin/main`. CI pipelines and other agents may push commits concurrently. Skipping this step will cause merge conflicts and rejected pushes.
### Releasing a New Version (CRITICAL WORKFLOW FOR AI AGENTS)
> [!CAUTION]
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
> The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json`. You do NOT need to manually bump version numbers.
> - **Website Versioning**: **NEVER** manually modify the version fallback strings in `website/index.html`. The website dynamically fetches the latest version and release date from `website/version.json` at runtime using `website/app.js`. Manual bumps in the HTML file are completely redundant and should be avoided.
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
2. Commit all verified code changes and push to `main`.
3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0``v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
- **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed.
4. The CI will extract the version from the tag (e.g., `v1.4.0``1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release.
5. Verify the release builds on GitHub Actions.
### 🚫 Force Push Policy
> [!CAUTION]
> **Force pushing (`git push --force` or `git push -f`) is FORBIDDEN without explicit user confirmation.**
> - If a push is rejected due to a non-fast-forward conflict, you **MUST** run `git pull --rebase` first.
> - If a force push is absolutely required (e.g., squashed history, amended commits), you **MUST** ask the user for explicit permission with a clear explanation of why it's necessary. Never force-push autonomously.
> - This applies to both branches (`main`) and **tags** (see Tag Immutability above). Force-pushing tags is doubly destructive. Never do it.
### Adding a Protocol Event
1. Add the event name to `shared/constants.js`.
2. Run the sync script (`.\scripts\sync-constants.bat` or `./scripts/sync-constants.sh`).
2. Run the build script (`node scripts/build-extension.js`).
3. Implement the handler in `server/index.js` and `background.js`.
### Testing Locally
1. Load `extension/` as an "Unpacked Extension" in Chrome.
2. Start the server from the root: `docker-compose up --build`.
3. Use **different browser profiles** or vendors to test multi-peer logic.
4. Use the **Dev tab** to verify real-time video element metadata.
1. Run the build script: `node scripts/build-extension.js`.
2. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (or `dist/firefox/` in Firefox).
3. Start the server from the root: `docker-compose up --build`.
4. Use **different browser profiles** or vendors to test multi-peer logic.
5. Use the **Dev tab** to verify real-time video element metadata.
### Locking Old Versions
1. Increase `APP_VERSION` in `shared/constants.js`.
2. Update `MIN_VERSION` in the server's `.env` file and restart.
1. Update `MIN_VERSION` in the server's `.env` file to the minimum acceptable version.
2. Restart the server. Older extensions will be rejected with a "Version too old" error.
-49
View File
@@ -1,49 +0,0 @@
# KoalaSync Architecture
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. 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.
- **Protocol Version**: Client must match the server's protocol (currently `1.0.0`).
3. Server responds with Engine.IO handshake (`0`) and the client joins the namespace (`40`).
- **Room Join**: Background emits `JOIN_ROOM` containing `roomId`, `password`, `peerId`, and `username`.
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers".
## 2. Media Event Synchronization
When a user interacts with a video:
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element.
2. **Prevention of Loops**: Uses `lastTargetState` to distinguish between user actions and programmatic actions triggered by the extension.
3. **Reporting**: `content.js` sends a `CONTENT_EVENT` to `background.js`.
4. **Relay**: The Server forwards the event to all other peers in the room.
5. **Execution**: Remote peers receive the command and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
## 3. Two-Phase Force Sync
Ensures all peers are frame-perfect and buffered before resuming:
1. **Prepare**: Initiator sends `FORCE_SYNC_PREPARE` with the target timestamp.
2. **Buffer**: Peers seek and pause. Once buffered (`readyState >= 3`), they send a `FORCE_SYNC_ACK`.
3. **Execute**: Once the Initiator collects ACKs (or after a 5s timeout), they send `FORCE_SYNC_EXECUTE`.
4. **Resume**: All peers call `play()` simultaneously.
## 4. Peer Lifecycle & Dual Heartbeat
To maintain a clean room state and eliminate "Ghost Peers":
- **Session Heartbeat (Background)**: Every 30 seconds, `background.js` sends an "I'm alive" signal to the server. This keeps you in the room even if no video is playing.
- **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.
## 5. Security & Stability
- **Service Worker Lifecycle**: Uses `chrome.alarms` to prevent the Manifest V3 service worker from suspending while in an active room.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS.
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
## 6. Constant Synchronization & Consistency
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 manual mirror of `EVENTS`.
- **Synchronization**: The `./scripts/sync-constants.sh` script ensures that the `shared/` folder within the `extension/` directory is kept up-to-date with the root `shared/` source.
- **Verification**: Any protocol change requires a manual verification sweep across all three constant locations (Shared, Server, and Content Script Mirror).
+58
View File
@@ -0,0 +1,58 @@
# Contributing to KoalaSync
Thank you for your interest in contributing to KoalaSync! We welcome all contributions, from bug reports to new features.
## Development Workflow
### 1. Prerequisites
- Node.js (v18+)
- Docker (for local server testing)
### 2. Setup
1. Clone the repository.
2. Run `npm install` in the root directory to install build dependencies.
3. Run the build script to synchronize protocol constants and generate browser bundles:
```bash
node scripts/build-extension.js
```
### 3. Testing Locally
1. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (`chrome://extensions/` → Developer Mode → Load Unpacked).
2. For Firefox, load `dist/firefox/` via `about:debugging` → "Load Temporary Add-on".
3. Start the relay server: `docker-compose up --build`.
4. Use **two different browser profiles** (or Chrome + Firefox) to test multi-peer synchronization.
5. Use the extension's **Dev tab** to verify real-time video element metadata (`readyState`, `currentTime`, `paused`).
### 4. Protocol Synchronization
KoalaSync uses a "Single Source of Truth" for protocol constants in `shared/constants.js`.
- **CRITICAL**: If you modify the constants, you MUST run the build script:
```bash
node scripts/build-extension.js
```
This will automatically synchronize the changes to the extension and generate the browser-specific bundles in the `dist/` folder.
### 5. Code Standards
- **Vanilla JS**: The extension must remain dependency-free. Do not add npm packages to the `extension/` directory.
- **Privacy**: Do not add external requests (CDNs, fonts, analytics, etc.).
- **Comments**: Maintain the existing documentation style, especially for complex sync logic.
- **Room IDs**: Room IDs are restricted to `[a-zA-Z0-9-]` (alphanumeric + hyphens only). Ensure any UI that generates room IDs follows this constraint.
### 6. Version Numbers
> [!IMPORTANT]
> **Do NOT manually bump version numbers.** The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manually changing version numbers in a PR will cause conflicts.
## Pull Request Process
1. Create a new branch for your feature or bugfix.
2. Ensure your code is tested locally (Chrome and Firefox).
3. Update relevant documentation (e.g., `docs/ARCHITECTURE.md` if you change the protocol).
4. Submit your PR with a clear description of the changes.
## Bug Reports
When reporting a bug, please include:
- **Browser**: Chrome / Firefox / Edge + version number.
- **Extension Version**: Visible in the popup's Dev tab.
- **Dev Tab Output**: Copy the connection status, logs, and video debug info from the Dev tab.
- **Steps to Reproduce**: A clear sequence of actions that triggers the issue.
## Security
If you find a security vulnerability, please do not open a public issue. Instead, refer to our [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
+46
View File
@@ -0,0 +1,46 @@
# KoalaSync - Production Caddy Configuration Example
# Replace domains and paths with your actual setup.
# 1. Marketing Website & Invitation Bridge
sync.koalastuff.net {
root * /var/www/koalasync/website
file_server
encode zstd gzip
# Static Caching for high-performance PageSpeed (1 year with validation)
@static {
file
path *.ico *.css *.js *.png *.svg *.webp
}
header @static Cache-Control "public, max-age=31536000, must-revalidate"
# Security Headers & Content Security Policy (CSP)
header {
# Strict Content Security Policy (restricts scripts and connections to self, forbids frames)
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';"
# Prevent FLoC tracking
Permissions-Policy interest-cohort=()
# Security best practices
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer-when-downgrade
}
}
# 2. Relay Server (Socket.IO / WebSocket)
syncserver.koalastuff.net {
reverse_proxy localhost:3000 {
# Ensure WebSocket support is explicitly handled if needed
# (Caddy usually handles this automatically)
header_up Host {host}
header_up X-Real-IP {remote_host}
}
# Security Headers for the relay
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
}
}
+49
View File
@@ -0,0 +1,49 @@
# Privacy Policy
**KoalaSync does not collect, store, or sell any personal data.**
KoalaSync is designed with a **Security-First & Volatile** architecture. This means we prioritize keeping your data out of persistent storage, though certain technical data must be processed temporarily to ensure service stability and security.
## 1. Data Processing (In-Memory Only)
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
- **Room Passwords**: If you set a room password, it is stored only as a secure **bcrypt hash** in RAM. The server never sees or stores your plaintext password.
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
### Data Retention
| Data Type | Maximum Retention | Trigger for Deletion |
|:----------|:------------------|:---------------------|
| Session data (peerId, username, video metadata) | Duration of session | User leaves room or disconnects |
| Room state | 2 hours max | Last peer leaves, or inactivity timeout |
| Auth failure records (lockout after 5 failed attempts) | 15 minutes | Periodic cleanup |
| Connection rate-limit counters | 60 seconds | Automatic expiry |
| Event rate-limit counters | 10 seconds | Automatic expiry + periodic cleanup |
## 2. Security & Rate Limiting
To prevent abuse and brute-force attacks, the following data is processed:
- **Brute-Force Protection**: If multiple failed password attempts are detected, the server stores the `IP address` and `Room ID` in a temporary RAM-based lockout list for a maximum of 15 minutes.
- **Connection Rate Limiting**: IP addresses are tracked for 60 seconds to prevent connection-flooding (DoS) attacks.
- **Event Rate Limiting**: Per-socket event counters are tracked for 10-second windows to prevent event-spamming. These are keyed by ephemeral socket IDs and cleaned up periodically.
- **Console Logging**: The official relay server (`syncserver.koalastuff.net`) outputs connection events (including IP addresses) to the server console for real-time monitoring. These logs are ephemeral and are not archived, sold, or linked to any persistent user identity.
## 3. Extension Permissions
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.
- `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.
## 4. Zero Third-Party Requests
KoalaSync is completely self-contained:
- **No CDNs or External Libraries**: All scripts and styles are self-hosted.
- **No Analytics**: We do not use Google Analytics, tracking pixels, or any third-party telemetry.
- **No External Fonts**: We use system font stacks to prevent tracking via font services.
## 5. Self-Hosted Instances
This privacy policy applies to the **official KoalaSync relay server** at `syncserver.koalastuff.net`. If you choose to self-host a relay server using our open-source Docker image, the data handling practices of that instance are the responsibility of the server operator.
---
**Auditable & Open Source**: Because KoalaSync is open source, you can verify these claims by reviewing the [Server Source Code](https://github.com/Shik3i/KoalaSync/blob/main/server/index.js) and the [Extension Logic](https://github.com/Shik3i/KoalaSync/blob/main/extension/content.js).
+83 -54
View File
@@ -1,73 +1,102 @@
# KoalaSync
<h1 align="center"><img src="extension/icons/icon128.png" width="32" valign="middle"> KoalaSync</h1>
KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchronized video playback across any website (YouTube, Twitch, Netflix, and custom HTML5 players).
<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://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>
**Latest Version**: `v1.2.0` (Episode Auto-Sync)
<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>
> [!TIP]
> **New Developers & AI Agents**: Please read [AI_INIT.md](AI_INIT.md) before starting work.
### 🌟 Why KoalaSync?
## Repository Structure
- `extension/`: Chrome Extension (Manifest V3, Vanilla JS).
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
- `website/`: Marketing landing page & **Invitation Bridge**.
- `shared/`: Protocol constants and domain blacklist.
- `scripts/`: Development utilities for protocol synchronization.
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture.
* **📡 Direct Logic**: Manual Socket.IO wire implementation for reliable synchronization.
* **🛠️ Clean Build**: Dependency-free extension runtime with no library overhead.
* **🌐 Universal**: Works on any website with a `<video>` tag.
> [!NOTE]
> For deep technical dives, see [ARCHITECTURE.md](ARCHITECTURE.md) and [SYNC_GUIDE.md](SYNC_GUIDE.md).
---
### ✨ Key Features
## Key Features
- **Global Synchronization**: Synchronize Play, Pause, and Seeking on any website with a `<video>` tag.
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together (v1.2.0+).
- **Smart Matching**: Automatically highlights and sorts tabs containing matching video titles.
- **Noise Filtering**: Built-in domain blacklist to hide non-video sites from selection.
- **Smart Identity**: Customizable usernames combined with unique hexadecimal peer IDs.
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together.
- **Smart Matching**: Automatically highlights tabs containing matching video titles.
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
- **Zero-Latency Relay**: Custom Socket.IO wire protocol implementation for maximum performance.
- **Integrated Diagnostics**: A dedicated "Dev" tab for real-time video state debugging.
- **Seamless Invitations**: Smart invitation links that automatically configure the server and room credentials for your friends.
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
- **Seamless Invitations**: Smart links that automatically configure server and room credentials for your friends.
---
## Setup Instructions
### 🚀 Quick Start
### 1. Relay Server (Docker)
The server runs on Node.js using Socket.IO, containerized for easy deployment.
#### For Users (Installation & Usage)
The easiest and safest way to install KoalaSync is directly through the official browser stores:
<p>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white&style=for-the-badge" alt="Chrome Extension"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white&style=for-the-badge" alt="Firefox Add-on"></a>
</p>
*(For manual offline installation: Download the latest `.zip` from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page and load it as an "Unpacked Extension" in Developer Mode).*
**How to use:**
1. **Create a Room:** Click the Koala icon in your browser and hit `+ Create New Room`.
2. **Invite Friends:** Share the auto-copied invite link. Once they click it, they automatically join.
3. **Pick a Video:** Navigate to the Sync Tab, select the tab playing your video, and grab some popcorn! 🍿
---
### 🛠️ For Developers & Self-Hosters
#### 📂 Repository Structure
- `extension/`: Browser Extension (Chrome & Firefox).
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
- `website/`: Marketing landing page & Invitation Bridge.
- `shared/`: **Single Source of Truth** for protocol constants.
- `scripts/`: Automated build and synchronization utilities.
- `docs/`: Technical deep-dives ([Architecture](docs/ARCHITECTURE.md), [Sync Guide](docs/SYNC_GUIDE.md)).
#### Building from Source
To build the extension from source and synchronize protocol constants:
```bash
# From the root directory
docker-compose up -d --build
npm install
node scripts/build-extension.js
```
The server will be available at `ws://localhost:3000`.
The compiled artifacts will be available in the `dist/` directory.
### 2. Chrome Extension
1. **Synchronize Protocol**: From the root directory, run the sync script to copy the master constants to the extension folder:
```bash
./scripts/sync-constants.sh
```
2. Open Chrome and go to `chrome://extensions/`.
3. Enable **Developer mode** (top right).
4. Click **Load unpacked**.
5. Select the `extension/` folder.
#### For Self-Hosting (Docker)
Deploy your own private relay server using our official image:
```bash
# Pull the latest image
docker pull ghcr.io/shik3i/koalasync:latest
## Usage
1. Open the extension and go to the **Settings** tab to set your **Username**.
2. Go to the **Room** tab, enter your Server URL (default: `ws://localhost:3000`), and click **Join / Create Room**.
3. In the **Sync** tab, select the tab containing the video you want to sync.
4. Share the **Invite Link** from the Room tab. When your friends click it, they will automatically join your room and server.
5. Use **Force Sync** to perfectly align everyone to your current timestamp.
# Or use our example compose file
cp docker-compose.example.yml docker-compose.yml
docker-compose up -d
```
The server will be available at `ws://localhost:3000`. See [docker-compose.example.yml](docker-compose.example.yml) for advanced configuration.
## Technical Details
- **Manifest V3**: Uses a persistent Service Worker with Alarm-based keep-alive.
- **Manual Socket.IO Protocol**: The extension implements the Socket.IO v4 wire protocol natively for extreme performance and zero dependencies.
- **Dead Peer Pruning**: The server automatically prunes peers after 5 minutes of total inactivity (detected via dual heartbeats).
- **Two-Phase Sync**: Ensures all peers are buffered (`readyState >= 3`) before resuming playback.
To connect your extension to a self-hosted server, open the popup → **Room** tab → select **Custom Server** → enter your server's WebSocket URL (e.g., `ws://localhost:3000`).
## Security & Privacy
> [!IMPORTANT]
> **Privacy First**: KoalaSync stores no data on disk. All room states exist only in RAM and are purged immediately when empty. There is zero telemetry, tracking, or analytics.
> **⚠️ Note**: `ws://` only works for `localhost`. If you deploy to a real domain, you **must** use `wss://` (e.g., `wss://sync.yourdomain.com`). This requires a TLS-terminating reverse proxy (e.g., Caddy, Nginx, or Traefik) in front of the relay server. See [Caddyfile.example](Caddyfile.example) for a production-ready template.
## Troubleshooting
- **Logs**: Check the **Dev** tab in the extension popup for live connection logs and video state diagnostics.
- **Handshake**: Verify you see `Joined Namespace /` in the logs.
- **Permissions**: Ensure the target site hasn't blocked script injection (rare for most video sites).
---
### 📖 Documentation & Links
- **[PRIVACY.md](PRIVACY.md)**: Data Handling and Privacy Policy.
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: How to help make KoalaSync better.
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
- **[Caddyfile.example](Caddyfile.example)**: Production Caddy configuration for website and relay.
---
<div align="center">
<sub>Built with ❤️ by <a href="https://github.com/Shik3i">Shik3i</a>. KoalaSync is Open Source under the <a href="LICENSE">MIT License</a>.</sub>
</div>
+23
View File
@@ -0,0 +1,23 @@
# Security Policy
## Supported Versions
We take the security of our users and their data very seriously. We actively support and patch the latest stable releases of KoalaSync.
| Version | Supported |
| -------------- | ------------------ |
| Latest Release | :white_check_mark: |
| Older Versions | :x: |
## Reporting a Vulnerability
If you discover a security vulnerability within KoalaSync (e.g., related to the Node.js relay server, WebSocket wire protocol, or the Chrome/Firefox browser extension), please **DO NOT** report it by creating a public GitHub issue.
Publicly disclosing a vulnerability before a patch is available puts our users at risk. Instead, please send an email privately to the project administrator at:
**koalasync_admin@koalamail.rocks**
### What to expect
1. **Acknowledgment**: You should receive an acknowledgment of your report within 48 hours.
2. **Investigation**: We will investigate the issue, confirm its severity, and work on a patch.
3. **Resolution**: We will notify you when the patch is deployed to the Chrome Web Store, Mozilla Add-on Store, and our GitHub Docker releases.
4. **Disclosure**: Once the fix is confirmed and users have had time to update, we will publicly acknowledge your contribution in our release notes (unless you prefer to remain anonymous).
-43
View File
@@ -1,43 +0,0 @@
# KoalaSync Protocol Synchronization Guide
## Why do we need to sync?
KoalaSync uses a "Single Source of Truth" for its communication protocol constants located in the root `shared/` directory. However, Chrome Extensions (Manifest V3) are strictly sandboxed and **cannot load or import files from outside their root directory**.
To ensure that the extension and the relay server are always using the exact same event names and protocol versions, we maintain a mirrored copy of the shared files within the `extension/shared/` folder.
## When should you run the sync script?
You MUST run the synchronization script in any of the following scenarios:
1. **After a fresh `git clone` or `git pull`** (as the synced files are ignored by git).
2. **After modifying** `shared/constants.js`.
3. **After modifying** `shared/blacklist.js`.
4. **Before committing** changes to the repository if any protocol-related files were touched.
5. **Before deploying** the server or releasing the extension.
## How to sync
### On Windows
Run the batch script from the repository root:
```powershell
.\scripts\sync-constants.bat
```
### On macOS / Linux
Run the shell script from the repository root:
```bash
./scripts/sync-constants.sh
```
## What does it do?
The script performs the following actions:
1. Ensures the `extension/shared/` directory exists.
2. Copies `shared/constants.js` to `extension/shared/constants.js`.
3. Copies `shared/blacklist.js` to `extension/shared/blacklist.js`.
## Protocol Versioning
As of v1.0.0-RC5, the system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
- The version is defined in `shared/constants.js`.
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
- **Always run the sync script** after bumping the version number to ensure both components are updated.
> [!CAUTION]
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the sync script is run. Always edit the files in the root `shared/` directory and then run the sync script.
Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

+25
View File
@@ -0,0 +1,25 @@
services:
koala-sync:
image: ghcr.io/shik3i/koalasync:latest
container_name: KoalaSync
restart: always
ports:
- "3000:3000"
environment:
- TZ=Europe/Berlin
- PORT=3000
- MIN_VERSION=1.0.0
- MAX_ROOMS=100
- MAX_PEERS_PER_ROOM=50
# KoalaSync uses in-memory storage for the relay,
# so no persistent database volume is required.
pids_limit: 2048
# Example for custom network (e.g., Unraid/Macvlan)
# networks:
# custom_network:
# ipv4_address: 192.168.1.XXX
# networks:
# custom_network:
# external: true
# name: br0
-1
View File
@@ -1,4 +1,3 @@
version: '3.8'
services:
server:
+74
View File
@@ -0,0 +1,74 @@
# KoalaSync Architecture
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. 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.
- **Protocol Version**: Client must match the server's protocol (currently `1.0.0`).
3. Server responds with Engine.IO handshake (`0`) and the client joins the namespace (`40`).
- **Room Join**: Background emits `JOIN_ROOM` containing `roomId`, `password`, `peerId`, and `username`.
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers". Deduplication re-validates after acquiring the room creation lock to avoid kicking the wrong socket during concurrent joins.
## 2. Media Event Synchronization
When a user interacts with a video:
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element, including videos inside Shadow DOM (YouTube, Netflix, etc.).
2. **Prevention of Loops**: Uses an `expectedEvents` Set to distinguish between user actions and programmatic actions. Expected events are consumed on match and expire via timeout. Timeout IDs are cleaned up immediately to prevent memory leaks.
3. **Reporting**: `content.js` sends a `CONTENT_EVENT` to `background.js`.
4. **Relay**: The Server forwards the event to all other peers in the room.
5. **Execution**: Remote peers receive the command via `SERVER_COMMAND` (which includes the original `senderId` for correct ACK routing) and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
6. **ACK Routing**: `content.js` echoes the `commandSenderId` back in `CMD_ACK`, ensuring the `EVENT_ACK` is routed to the correct initiating peer even when multiple commands arrive concurrently.
## 3. Two-Phase Force Sync
Ensures all peers are buffered and synchronized before resuming:
1. **Prepare**: Initiator sends `FORCE_SYNC_PREPARE` with the target timestamp.
2. **Buffer**: Peers seek and pause. Once buffered (`readyState >= 3`), they send a `FORCE_SYNC_ACK`. (Note: `content.js` limits polling to 8000ms).
3. **Execute**: Once the Initiator collects ACKs (or after an 8.5s timeout), they send `FORCE_SYNC_EXECUTE`.
> [!IMPORTANT]
> **Network Transit Buffer Rule**: The orchestrator (`background.js`) must always use a timeout at least 500ms longer than the worker (`content.js`) to account for IPC and network transit time. Never align them exactly 1:1, as this will introduce a race condition on slow connections.
4. **Resume**: All peers call `play()` simultaneously.
## 4. Episode Auto-Sync
Maintains continuous synchronized viewing when watching series:
1. **Detection**: `content.js` monitors the Media Session API for title changes.
2. **Lobby Creation**: When a new title is detected, the peer initiates an `EPISODE_LOBBY` and broadcasts the new title.
3. **Wait State**: All peers freeze their video until they have also loaded the exact same title.
4. **Mid-Lobby Joins**: If a new user joins the room during an active lobby, the lobby initiator broadcasts the active lobby state so the newcomer can sync up.
5. **Resume**: Once all peers report `EPISODE_READY`, the lobby is resolved and playback resumes perfectly.
## 5. Peer Lifecycle & Dual Heartbeat
To maintain a clean room state and eliminate "Ghost Peers":
- **Session Heartbeat (Background)**: Every 30 seconds, `background.js` sends an "I'm alive" signal to the server. This keeps you in the room even if no video is playing.
- **Video Heartbeat (Content)**: Every 15 seconds, `content.js` sends current playback metadata (time, title, state) if a video is found.
- **Server Pruning**: The server runs a "Reaper" every 2 minutes. If a peer has sent **zero** activity (no events and no heartbeats) for 5 minutes, they are forcefully disconnected.
- **Immediate Cleanup**: Rooms are deleted instantly when the last peer leaves or disconnects.
- **Reconnect Strategy**: Aggressive backoff — 500ms base, 1.5x multiplier, capped at 5s. Max 20 attempts before marking as failed. Events are queued during disconnect and flushed after namespace rejoin.
> [!CAUTION]
> **Identity Rule**: Differentiate between `peerId` and `socket.id`. Use `socket.id` exclusively for ephemeral transport routing on the server. Use `peerId` exclusively for identity, state management, and room tracking across the stack.
## 6. Broadcast Protocol & Routing
KoalaSync uses a megaphone routing approach to minimize server logic:
- **`emit()` Broadcast Behavior**: Any `emit()` from the extension client is unconditionally broadcast to **all other peers in the room**. It is not a direct message.
- **Storm Prevention**: When dispatching state updates in response to a new user joining (e.g., an active lobby state), ensure ONLY the initiator (or a designated leader) calls `emit()` to prevent $O(N)$ broadcast storms.
## 7. Security & Stability
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS. Real client IP extracted via `x-forwarded-for` header behind proxies/CDNs.
- **Room Creation Lock**: Per-room mutex prevents race conditions when multiple peers join a new room simultaneously.
- **CORS**: Allows `chrome-extension://` origins for WebSocket fallback compatibility.
- **Message Buffer**: `maxHttpBufferSize` set to 4KB to accommodate large `JOIN_ROOM` payloads.
- **Process Guards**: `uncaughtException` and `unhandledRejection` handlers prevent silent server crashes.
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
## 8. Constant Synchronization & Consistency
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.
- **Verification**: Any protocol change is automatically propagated across the stack by running the build script.
+213
View File
@@ -0,0 +1,213 @@
# KoalaSync — How It Works (Step-by-Step)
This guide walks through the complete user flow of KoalaSync, from creating a room to synchronized playback. It is designed for **store reviewers**, **end-users**, and **manual testers** to understand exactly what happens at each step, what data is sent, and where it goes.
---
## Step 1: Installing the Extension
1. Download the extension from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page (or install from the Chrome Web Store / Firefox Add-ons).
2. The extension adds a small icon to your browser toolbar.
3. On first install, a unique 8-character **Peer ID** is generated locally and stored in `chrome.storage.local`. This ID is never sent to any external service — it only travels to the relay server when you join a room.
> **What's stored locally**: `peerId` (8-char hex), `username` (customizable, defaults to a readable adjective-noun pair), `serverUrl`, `filterNoise` preference. All stored via `chrome.storage.sync` and `chrome.storage.local`.
---
## Step 2: Connecting to the Relay Server
When you open the extension popup, 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`.
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.
- The server checks the **extension version** against `MIN_VERSION` to reject outdated clients.
3. **Connection Established**: The server responds with an Engine.IO handshake (`0{...}`), followed by a Socket.IO namespace join (`40`). The connection status dot in the popup turns green.
> **Data sent to server**: `token` (authentication), `version` (e.g., `1.3.1`). No personal data is transmitted during connection.
---
## Step 3: Creating a Room
Click **"Create Room"** in the popup's Room tab:
1. The extension generates a random Room ID (e.g., `happy-koala-42`) and a random 6-character password.
2. Room IDs are restricted to `[a-zA-Z0-9-]` (alphanumeric + hyphens only).
3. The extension emits a `JOIN_ROOM` event to the server.
> **Data sent in `JOIN_ROOM`**:
> ```json
> {
> "roomId": "happy-koala-42",
> "password": "x7k2m9",
> "peerId": "a1b2c3d4",
> "username": "MyName",
> "tabTitle": "YouTube - My Video",
> "protocolVersion": "1.0.0"
> }
> ```
4. **Server-side processing**:
- All fields are **sanitized**: `roomId` is stripped of invalid characters and clamped to 64 chars; `peerId` clamped to 16 chars; `password` clamped to 128 chars; `username` clamped to 30 chars.
- The server **hashes the password** with bcrypt and stores the hash in RAM (the plaintext is never stored).
- A new room object is created in memory with the peer's data.
- The server responds with `ROOM_DATA` containing the list of peers in the room.
5. **Popup updates**: The Room tab switches to the "Active Room" view, showing your Room ID and an invitation link.
---
## Step 4: Sharing an Invitation Link
Click the **📋 Copy** button next to the invite link:
1. The extension constructs a URL in this format:
```
https://sync.koalastuff.net/join.html#join:<roomId>:<password>:<serverFlag>:<encodedServerUrl>
```
- `serverFlag`: `0` for official server, `1` for custom server.
- `encodedServerUrl`: Only populated if using a custom server.
2. **Important**: The room credentials are in the **URL hash** (`#`), which means they are **never sent to the web server** — the hash fragment stays entirely in the browser. The landing page server never sees your room ID or password.
3. Send this link to your friend via any messaging app.
---
## Step 5: Your Friend Opens the Invitation Link
When your friend opens the link in their browser:
1. **`join.html` loads** on `sync.koalastuff.net`. The page displays "INVITATION DETECTED" with the Room ID.
2. **Extension detection**: The page checks for `document.documentElement.dataset.koalasyncInstalled`, which is set by `bridge.js` (a content script injected only on `sync.koalastuff.net`).
3. **If the extension IS installed**:
- The page shows "Joining room automatically..."
- After 500ms, the page dispatches a `KOALASYNC_JOIN_REQUEST` custom DOM event with `{ roomId, password, useCustomServer, serverUrl }`.
- `bridge.js` catches this event and forwards it to `background.js` via `chrome.runtime.sendMessage`.
- `background.js` stores the credentials in `chrome.storage.sync` and emits `JOIN_ROOM` to the server.
- The server validates the password against the stored bcrypt hash.
- On success, the server responds with `ROOM_DATA` and broadcasts `PEER_STATUS { status: 'joined' }` to all existing peers.
- The join page updates to show "✅ Successfully joined!".
4. **If the extension is NOT installed**:
- The page shows download links (Chrome Web Store / GitHub).
- The user installs the extension, returns to the link, and the flow continues from step 3.
---
## Step 6: Selecting a Video Tab
Both users now need to select which browser tab contains the video to sync:
1. Open a video on any website (YouTube, Twitch, Netflix, etc.).
2. In the extension popup → **Sync** tab → use the **"Target Tab"** dropdown.
3. The dropdown lists all open tabs, filtered to exclude noise (search engines, social media — configurable via Settings).
4. Tabs with a **matching video title** are highlighted with a ⭐ prefix for easy identification.
5. Selecting a tab causes `background.js` to set `currentTabId` and inject `content.js` into that tab via `chrome.scripting.executeScript`.
> **What `content.js` does on injection**: Finds the first `<video>` element on the page and attaches event listeners for `play`, `pause`, `seeked`, and `loadeddata`. (Time and volume state are tracked via a 15-second heartbeat interval, not continuous event listeners). It uses an `expectedEvents` Set to distinguish between user actions and programmatic actions (loop prevention).
---
## Step 7: Synchronized Playback
When User A presses **Play** on their video:
1. `content.js` detects the native `play` event on the `<video>` element.
2. It checks the `expectedEvents` Set — if this event was expected (caused by a remote command), it's consumed silently. If not, it's a **user action**.
3. For user actions, `content.js` sends `{ type: 'CONTENT_EVENT', action: 'play', payload: { currentTime, ... } }` to `background.js`.
4. `background.js` adds an `actionTimestamp` and emits the `PLAY` event to the server.
5. **Server relay**: The server sanitizes all fields (strings clamped, numbers validated, booleans type-checked) and constructs a clean `relayPayload` with `senderId` set to User A's `peerId`. The raw client data is never forwarded directly.
6. The server broadcasts the sanitized payload to all other peers in the room.
7. User B's `background.js` receives the `PLAY` event and calls `routeToContent()`, which sends a `SERVER_COMMAND` message to User B's `content.js`.
8. User B's `content.js` adds `'playing'` to its `expectedEvents` Set (so it won't echo the event back), then calls `video.play()`.
> **The same flow applies to Pause and Seek**, with Seek additionally sending `targetTime` for the time position.
---
## Step 8: Force Sync (Two-Phase Protocol)
If videos drift out of sync, either user can click **"Force Sync"**:
### Phase 1 — Prepare
1. The initiator's `content.js` captures the current `video.currentTime` as the `targetTime`.
2. `background.js` emits `FORCE_SYNC_PREPARE` with `{ targetTime }` to all peers.
3. All peers (including the initiator) **pause** their video and **seek** to `targetTime`.
4. Each peer's `content.js` polls `video.readyState` until it reaches `≥ 3` (buffered enough to play), with an 8-second timeout.
5. Once buffered, each peer sends `FORCE_SYNC_ACK` back.
### Phase 2 — Execute
6. Once all ACKs are received (or after 8.5 seconds), the initiator emits `FORCE_SYNC_EXECUTE`.
7. All peers call `video.play()` simultaneously, achieving synchronized playback.
> **Why two phases?** Without buffering confirmation, peers with slower connections would start playing before they've loaded the target timestamp, causing immediate desync.
---
## Step 9: Heartbeat & Peer Health
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) |
| **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").
- **Room Cleanup**: Empty rooms are deleted immediately. Inactive rooms are pruned after 2 hours.
---
## Step 10: Leaving a Room
When a user clicks **"Leave"** or closes their browser:
1. `background.js` emits `LEAVE_ROOM` (or the WebSocket `disconnect` fires automatically).
2. The server calls `removePeerFromRoom()`, which:
- Removes the peer from the room's `peers` Set, `peerIds` Map, and `peerData` Map.
- Removes the socket from the global `socketToRoom` and `peerToSocket` maps.
- Broadcasts `PEER_STATUS { status: 'left' }` to remaining peers.
- If the room is now empty, **deletes the room entirely** — no data persists.
3. The event rate-limit counter for that socket is also cleaned up.
> **After disconnect, zero data about the user remains on the server.** There is no database, no log file, no analytics record. The session existed only in RAM and is now gone.
---
## Episode Auto-Sync Flow
When watching a series and an episode ends:
1. `content.js` monitors the [Media Session API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API) for title changes.
2. When a new title is detected, the peer broadcasts `EPISODE_LOBBY` with the expected new title.
3. All peers' videos freeze. The UI shows an "Episode Lobby" card with peer readiness status.
4. Each peer's `content.js` polls for the new title to appear in the `<video>` element's metadata.
5. Once a peer detects the matching title, they send `EPISODE_READY`.
6. When all peers report ready, the lobby resolves and playback resumes simultaneously.
---
## Data Flow Summary
```
┌─────────────┐ WebSocket ┌──────────────┐ WebSocket ┌─────────────┐
│ Extension │ ←─────────────────→│ Relay Server │←──────────────────→│ Extension │
│ (User A) │ JOIN_ROOM │ (RAM only) │ JOIN_ROOM │ (User B) │
│ │ PLAY/PAUSE/SEEK │ │ PLAY/PAUSE/SEEK │ │
│ │ FORCE_SYNC_* │ Sanitizes & │ FORCE_SYNC_* │ │
│ │ PEER_STATUS │ relays only │ PEER_STATUS │ │
│ │ EPISODE_* │ │ EPISODE_* │ │
└──────┬──────┘ └───────────────┘ └──────┬──────┘
│ │
┌────┴─────┐ ┌─────┴────┐
│ content │ Listens to <video> events │ content │
│ .js │ Controls playback │ .js │
└──────────┘ └──────────┘
```
> **The relay server is a pure message forwarder.** It never interprets video content, accesses URLs, or stores session history. All media control happens locally inside each user's browser via the `<video>` DOM API.
+7
View File
@@ -0,0 +1,7 @@
# Technical Documentation
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.
+26
View File
@@ -0,0 +1,26 @@
# KoalaSync Roadmap
This document tracks planned features, improvements, and their implementation details.
---
## Offene technische Fragen
### 1. Service Worker Fallback bei Room-State Verlust
Manifest V3 suspendiert den Service Worker nach ~30s Inaktivität. `chrome.alarms` weckt ihn auf, aber:
- **Problem:** Wenn der SW neu startet, sind alle Variablen (`currentRoom`, `socket`, `isNamespaceJoined`) weg
- **Aktueller Stand:** `chrome.storage.session` persistiert `currentRoom`, `peerId`, `eventQueue` — der SW stellt diese beim Start wieder her (`ensureState()`)
- **Gelöst:** WebSocket wird automatisch via `connect()` neu aufgebaut. Events werden während Reconnect gequeued und nach Namespace-Join geflushed. "Reconnecting..." Status wird im Popup + Badge angezeigt. KeepAlive-Alarm auf 30s reduziert. Reconnect-Backoff: 500ms Basis, max 5s (statt vorher 1s→30s).
### 7. Tests für Extensions
Stimmt, sind aufwändig. Praktische Ansätze:
- **Unit Tests:** `jest` + `jest-chrome` (mockt `chrome.*` APIs) — testet `popup.js` Logik, Server-Logik
- **Integration Tests:** `puppeteer` mit `--load-extension` Flag — testet Extension im echten Browser
- **Server Tests:** `supertest` + `socket.io-client` — testet WebSocket-Flows
- **Aufwand:** ~400-600 LOC für sinnvolle Testabdeckung der Kernlogik
---
## Zukünftige Features
Neue Features werden nur nach expliziter Freigabe hinzugefügt.
+37
View File
@@ -0,0 +1,37 @@
# KoalaSync Protocol Synchronization Guide
## Why do we need to sync?
KoalaSync uses a "Single Source of Truth" for its communication protocol constants located in the root `shared/` directory. However, Browser Extensions (Manifest V3) are strictly sandboxed and **cannot load or import files from outside their root directory**.
To ensure that the extension and the relay server are always using the exact same event names and protocol versions, we maintain a mirrored copy of the shared files within the `extension/shared/` folder.
## When should you run the build script?
You MUST run the build script in any of the following scenarios:
1. **After a fresh `git clone` or `git pull`** (as the synced files are ignored by git).
2. **After modifying** `shared/constants.js`.
3. **After modifying** `shared/blacklist.js`.
4. **Before committing** changes to the repository if any protocol-related files were touched.
5. **Before deploying** the server or releasing the extension.
## How to sync
Run the Node.js build script from the repository root:
```bash
node scripts/build-extension.js
```
## 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.
3. Compiles browser-specific manifest files.
4. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
## Protocol Versioning
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
- The version is defined in `shared/constants.js`.
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
- **Always run the build script** after bumping the version number to ensure both components are updated.
> [!CAUTION]
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
+68
View File
@@ -0,0 +1,68 @@
export default [
{
ignores: ["dist/**", "node_modules/**", "scratch/**"]
},
{
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
chrome: "readonly",
window: "readonly",
document: "readonly",
navigator: "readonly",
console: "readonly",
localStorage: "readonly",
setTimeout: "readonly",
setInterval: "readonly",
clearTimeout: "readonly",
clearInterval: "readonly",
fetch: "readonly",
CustomEvent: "readonly",
MutationObserver: "readonly",
Uint32Array: "readonly",
Set: "readonly",
Map: "readonly",
Promise: "readonly",
Array: "readonly",
Object: "readonly",
JSON: "readonly",
Math: "readonly",
Number: "readonly",
String: "readonly",
Date: "readonly",
Error: "readonly",
URL: "readonly",
WebSocket: "readonly",
self: "readonly"
}
},
rules: {
"no-undef": "error",
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }],
"no-unreachable": "error",
"no-constant-condition": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-extra-semi": "error",
"no-prototype-builtins": "warn",
"no-unsafe-optional-chaining": "error",
"valid-typeof": "error"
}
},
{
files: ["server/**/*.js", "scripts/**/*.js"],
languageOptions: {
globals: {
require: "readonly",
__dirname: "readonly",
__filename: "readonly",
process: "readonly",
module: "readonly",
exports: "readonly",
Buffer: "readonly"
}
}
}
];
+13 -7
View File
@@ -1,6 +1,6 @@
# KoalaSync Chrome Extension
# KoalaSync Browser Extension
A Manifest V3 Chrome Extension for synchronized video playback across any website.
A Manifest V3 Browser Extension (Chrome & Firefox) for synchronized video playback across any website.
## Key Features
- **Manifest V3**: Optimized Service Worker architecture with session persistence.
@@ -20,14 +20,20 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
- **No Browsing History**: We do not track or store your browsing history.
- **State Management**: Sensitive data (Room Passwords) is stored locally using `chrome.storage`.
- **Zero Telemetry**: No analytics or external tracking scripts.
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring performance and privacy.
## Installation
1. **Sync Protocol**: Run `./scripts/sync-constants.sh` (macOS/Linux) or `scripts\sync-constants.bat` (Windows) from the root.
1. **Prepare Extension**: From the repository root, run:
```bash
node scripts/build-extension.js
```
2. Open Chrome and go to `chrome://extensions/`.
3. Enable **Developer mode** (top right).
4. Click **Load unpacked** and select the `extension` folder.
4. Click **Load unpacked** and select the `dist/chrome` folder.
## Development
If you modify `shared/constants.js`, you must synchronize the changes across the extension and server:
- **Windows**: Run `scripts\sync-constants.bat`
- **Linux/macOS**: Run `scripts/sync-constants.sh`
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
```
This ensures that the `extension/shared` folder is updated with the latest protocol constants.
+486 -103
View File
File diff suppressed because it is too large Load Diff
+12 -8
View File
@@ -1,6 +1,7 @@
/* global cloneInto */
/**
* KoalaSync Bridge Script
* Injected into koalasync.shik3i.net to facilitate communication between
* Injected into sync.koalastuff.net to facilitate communication between
* the landing page and the extension.
*/
@@ -22,12 +23,15 @@ window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
// 3. Listen for Status Updates from the Extension and relay to Website
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'JOIN_STATUS') {
const event = new CustomEvent('KOALASYNC_STATUS', {
detail: {
success: msg.success,
message: msg.message
}
});
window.dispatchEvent(event);
const detail = { success: msg.success, message: msg.message };
// Firefox MV3 content scripts run in an isolated world. When dispatching
// a CustomEvent with a detail object, Firefox wraps it in an XrayWrapper
// that the page's JavaScript cannot destructure (Permission denied).
// cloneInto() exposes the object to the page's context correctly.
// Chrome doesn't have this issue — cloneInto() is undefined there.
const safeDetail = typeof cloneInto === 'function'
? cloneInto(detail, document.defaultView)
: detail;
window.dispatchEvent(new CustomEvent('KOALASYNC_STATUS', { detail: safeDetail }));
}
});
+293 -88
View File
@@ -9,12 +9,13 @@
if (window.koalaSyncInjected && chrome.runtime.id) {
return;
}
} catch (e) {
} catch (_e) {
// Context invalidated, proceed with re-injection
}
window.koalaSyncInjected = true;
// Local Protocol Constants (Mirroring shared/constants.js)
// --- SHARED_EVENTS_INJECT_START ---
// This block is automatically updated by /scripts/build-extension.js
const EVENTS = {
PLAY: "play",
PAUSE: "pause",
@@ -26,9 +27,27 @@
EPISODE_LOBBY: "episode_lobby",
EPISODE_READY: "episode_ready"
};
// --- SHARED_EVENTS_INJECT_END ---
let expectedEvents = new Set();
let expectedTimeouts = {};
// Suppresses native event reporting after a programmatic action.
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
function _setSuppress(state) {
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
_suppressTimers[state] = setTimeout(() => {
delete _suppressTimers[state];
}, 300);
}
function _clearSuppress(state) {
if (_suppressTimers[state]) {
clearTimeout(_suppressTimers[state]);
delete _suppressTimers[state];
}
}
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
@@ -40,26 +59,35 @@
// --- Episode Auto-Sync State ---
let lastKnownMediaTitle = null;
let episodeTransitionDebounce = null;
let pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null;
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
function expectEvent(state) {
expectedEvents.add(state);
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
const timeout = state === 'seek' ? 10000 : 1500;
expectedTimeouts[state] = setTimeout(() => {
expectedEvents.delete(state);
}, timeout);
}
// Cache the autoSyncNextEpisode setting
chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => {
_autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && changes.autoSyncNextEpisode) {
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
}
});
function reportLog(message, level = 'info') {
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
}
// --- Helper: find the best video element on the page ---
function findVideo() {
const videos = document.querySelectorAll('video');
return videos.length > 0 ? videos[0] : null;
function findVideo(root = document) {
const video = root.querySelector('video');
if (video) return video;
for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) {
const found = findVideo(el.shadowRoot);
if (found) return found;
}
}
return null;
}
// --- Episode Auto-Sync: Detection ---
@@ -69,6 +97,43 @@
: null;
}
// Extract a canonical episode identifier from a title string.
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
// Returns null if no episode pattern found.
function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null;
// S01E01 patterns (with optional spaces, dashes, dots between S and E)
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')}`;
// "Episode X", "Folge X", "Ep. X", "#X"
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
return null;
}
// Returns true if two titles likely refer to the same episode.
// Strict: both must have IDs and match, OR neither has IDs and exact match.
function sameEpisode(titleA, titleB) {
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
if (!titleA || !titleB) return false; // One unknown, one known → different
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
if (idA || idB) return false; // One has ID, other doesn't → different
return titleA === titleB; // Neither has ID → exact string match
}
// Returns true only when we are CERTAIN the episodes differ.
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
// Films, music, unparseable titles always pass through.
function isDifferentEpisode(titleA, titleB) {
if (!titleA || !titleB) return false; // Unknown → allow
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
if (!idA || !idB) return false; // At least one unparseable → allow
return idA !== idB; // Both parseable → only block if different
}
function checkEpisodeTransition() {
const currentTitle = getMediaTitle();
const video = findVideo();
@@ -76,7 +141,7 @@
// Only trigger if: we had a previous title, the title changed,
// a video exists, and we're near the start of new content.
if (lastKnownMediaTitle && currentTitle
&& currentTitle !== lastKnownMediaTitle
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
&& video
&& video.currentTime < 5
&& video.readyState >= 1) {
@@ -109,11 +174,11 @@
const video = findVideo();
const currentTitle = getMediaTitle();
if (video && currentTitle && currentTitle === expectedTitle
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& video.currentTime < 5 && video.readyState >= 1) {
// Match! Pause at start and report ready.
if (!video.paused) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
}
stopLobbyPoll();
@@ -129,7 +194,7 @@
function startLobbyPoll(expectedTitle) {
stopLobbyPoll();
pendingLobbyTitle = expectedTitle;
_pendingLobbyTitle = expectedTitle;
// NOTE: Do NOT pause here. Three callers reach this function:
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
@@ -149,7 +214,7 @@
function stopLobbyPoll() {
pendingLobbyTitle = null;
_pendingLobbyTitle = null;
if (lobbyPollTimer) {
clearInterval(lobbyPollTimer);
lobbyPollTimer = null;
@@ -167,7 +232,7 @@
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
return;
}
data.targetTime = target;
data = { ...data, targetTime: target };
}
try {
@@ -180,11 +245,11 @@
if (ytButton) {
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
ytButton.click();
}
if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
return;
@@ -196,11 +261,11 @@
if (twitchButton) {
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
twitchButton.click();
}
if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
return;
@@ -209,19 +274,19 @@
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
expectEvent('playing');
_setSuppress('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
expectedEvents.delete('playing');
_clearSuppress('playing');
});
} else if (action === EVENTS.PAUSE) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
} else if (action === EVENTS.SEEK) {
expectEvent('seek');
_setSuppress('seek');
video.currentTime = data.targetTime;
}
} catch (e) {
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
}
}
@@ -257,22 +322,44 @@
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'get_current_time') {
const video = findVideo();
sendResponse({ currentTime: video ? video.currentTime : undefined });
sendResponse({ currentTime: video ? video.currentTime : null });
return true;
}
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
let actionCompleted = false;
// Guard: Don't execute sync commands if peers are on different episodes.
// Only active when autoSyncNextEpisode setting is enabled (default: on).
// Only blocks when BOTH sides have parseable S01E01-style IDs that differ.
// Films and unparseable titles always pass through.
const syncActions = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
if (_autoSyncEnabled && syncActions.includes(action)) {
const senderTitle = payload?.mediaTitle;
const myTitle = getMediaTitle();
if (isDifferentEpisode(senderTitle, myTitle)) {
reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn');
if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) {
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
}
return;
}
}
if (action === EVENTS.PLAY) {
tryMediaAction(EVENTS.PLAY);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.PAUSE) {
tryMediaAction(EVENTS.PAUSE);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.SEEK) {
tryMediaAction(EVENTS.SEEK, payload);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.FORCE_SYNC_PREPARE) {
if (!payload || payload.targetTime === undefined) return;
const video = findVideo();
@@ -281,18 +368,28 @@
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
return;
}
expectEvent('paused');
expectEvent('seek');
_setSuppress('paused');
_setSuppress('seek');
video.pause();
video.currentTime = payload.targetTime;
pollSeekReady(payload.targetTime).then((ready) => {
if (ready) chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
});
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
if (ready) {
scheduleProactiveHeartbeat();
} else {
reportLog('Force Sync: Seek ready timeout, proceeding anyway', 'warn');
}
}).catch(() => {});
}
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
stopLobbyPoll(); // Clear any pending lobby on force sync
stopLobbyPoll();
tryMediaAction(EVENTS.PLAY);
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
}
if (actionCompleted) {
scheduleProactiveHeartbeat();
}
}
@@ -318,7 +415,7 @@
if (message.type === 'PAUSE_FOR_LOBBY') {
const video = findVideo();
if (video && !video.paused) {
expectEvent('paused');
_setSuppress('paused');
video.pause();
}
// Start lobby poll now that we know the feature is enabled
@@ -372,6 +469,17 @@
// Detect native events
function reportEvent(action) {
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
clearTimeout(seekDebounceTimer);
seekDebounceTimer = null;
const v = findVideo();
if (v && Number.isFinite(v.currentTime)) {
lastReportedSeekTime = v.currentTime;
reportLog(`[Seek] Debounce flushed immediately due to ${action.toUpperCase()}`, 'info');
reportEvent(EVENTS.SEEK);
}
}
const video = findVideo();
if (!video) return;
@@ -382,10 +490,14 @@
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
if (eventState && expectedEvents.has(eventState)) {
expectedEvents.delete(eventState); // Consume the match
return; // Ignore event caused by our programmatic action
if (_suppressTimers[eventState]) {
_clearSuppress(eventState);
return;
}
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
// Play/Pause pass through — user may want to immediately pause after tabbing back.
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
@@ -397,8 +509,43 @@
timestamp: Date.now()
}
});
// Trigger proactive heartbeat to push stabilized state
scheduleProactiveHeartbeat();
}
// --- Tab Visibility Handling ---
// Browsers (especially Firefox) aggressively throttle background tabs.
// When the user returns to a video tab, the video element may have lost
// time-sync and fires spurious seek events as it recovers (jumping back).
// We suppress only SEEK for a short grace period after tab re-focus.
// Play/Pause are NOT suppressed — the user may legitimately want to
// pause immediately after switching back.
let pageVisible = !document.hidden;
let visibilityGraceUntil = 0;
const VISIBILITY_GRACE_MS = 300;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
pageVisible = false;
} else if (!pageVisible) {
pageVisible = true;
visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS;
reportLog(`Tab re-focused — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s to prevent ghost relay`, 'warn');
}
});
// Reset on page hide/show (bfcache, tab discard)
window.addEventListener('pagehide', () => { pageVisible = false; });
window.addEventListener('pageshow', (event) => {
// event.persisted is true ONLY when restored from bfcache, not on initial load
if (event.persisted && !pageVisible) {
pageVisible = true;
visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS;
reportLog(`Page restored from cache — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s`, 'warn');
}
});
const handlePlay = () => reportEvent(EVENTS.PLAY);
const handlePause = () => reportEvent(EVENTS.PAUSE);
@@ -410,24 +557,26 @@
const current = video.currentTime;
if (!Number.isFinite(current)) return;
// Step 1: Check expectedEvents (programmatic seek suppression)
if (expectedEvents.has('seek')) {
expectedEvents.delete('seek');
lastReportedSeekTime = current; // Update baseline so next user seek is relative to here
// No log — this is routine programmatic behavior (Force Sync, lobby, peer command)
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
if (_suppressTimers['seek']) {
_clearSuppress('seek');
lastReportedSeekTime = current;
return;
}
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
if (Date.now() < visibilityGraceUntil) return;
const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null;
const deltaStr = delta !== null ? `Δ${delta.toFixed(2)}s` : 'Δ?';
// Step 2: Delta check — skip micro-seeks (buffering, chapter markers, etc.)
// Step 3: Delta check — skip micro-seeks (buffering, chapter markers, etc.)
if (lastReportedSeekTime !== null && delta < MIN_SEEK_DELTA) {
reportLog(`[Seek] Filtered (${deltaStr} < ${MIN_SEEK_DELTA}s threshold) @ ${current.toFixed(2)}s — not relayed`, 'warn');
return;
}
// Step 3: Debounce rapid consecutive seeks (e.g. scrubbing)
// Step 4: Debounce rapid consecutive seeks (e.g. scrubbing)
// — wait 800ms for the user to settle before relaying
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
seekDebounceTimer = setTimeout(() => {
@@ -444,7 +593,7 @@
};
let lastVideoSrc = null;
let lastVideoSrc = undefined;
// Episode detection handler for loadeddata event
const handleLoadedData = () => {
@@ -454,19 +603,22 @@
function setupListeners() {
const video = findVideo();
if (video) {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('seeked', handleSeeked);
video.removeEventListener('loadeddata', handleLoadedData);
const existing = video._koalaHandlers;
if (existing) {
video.removeEventListener('play', existing.play);
video.removeEventListener('pause', existing.pause);
video.removeEventListener('seeked', existing.seeked);
video.removeEventListener('loadeddata', existing.loadeddata);
}
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData };
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('loadeddata', handleLoadedData);
video.dataset.koalaAttached = 'true';
lastVideoSrc = video.currentSrc || video.src;
lastVideoSrc = video.currentSrc || video.src || null;
// Initialize episode tracking title on first attach
if (!lastKnownMediaTitle) {
lastKnownMediaTitle = getMediaTitle();
}
@@ -480,13 +632,19 @@
function checkVideo() {
lastMutate = Date.now();
const video = findVideo();
if (!video && lastVideoSrc !== undefined) {
reportLog('Video element removed from page', 'warn');
lastVideoSrc = undefined;
return;
}
if (!video) return;
const currentSrc = video.currentSrc || video.src;
const currentSrc = video.currentSrc || video.src || null;
if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) {
// If src changed, also check for episode transition
if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) {
if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) {
if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) {
checkEpisodeTransition();
}
setupListeners();
@@ -502,41 +660,88 @@
observerTimeout = setTimeout(checkVideo, 1000 - (now - lastMutate));
}
});
observer.observe(document.body, { childList: true, subtree: true });
observer.observe(document.documentElement, { childList: true, subtree: true });
// Heartbeat
// --- SHARED_HEARTBEAT_INJECT_START ---
const HEARTBEAT_INTERVAL_VAL = 15000;
// --- SHARED_HEARTBEAT_INJECT_END ---
// Heartbeat Refactoring (Self-scheduling setTimeout with proactive heartbeat scheduling)
let heartbeatTimeout = null;
let proactiveHeartbeatTimeout = null;
let heartbeatErrorCount = 0;
const heartbeatInterval = setInterval(() => {
function sendHeartbeat() {
const video = findVideo();
if (video) {
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
chrome.runtime.sendMessage({
type: 'HEARTBEAT',
payload: {
playbackState: video.paused ? 'paused' : 'playing',
currentTime: video.currentTime,
mediaTitle: mediaTitle,
volume: video.volume,
muted: video.muted
if (!video) return;
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
chrome.runtime.sendMessage({
type: 'HEARTBEAT',
payload: {
playbackState: video.paused ? 'paused' : 'playing',
currentTime: video.currentTime,
mediaTitle: mediaTitle,
volume: video.volume,
muted: video.muted
}
}).catch(err => {
if (err.message.includes('Extension context invalidated')) {
heartbeatErrorCount++;
if (heartbeatErrorCount === 1) {
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
}
}).catch(err => {
if (err.message.includes('Extension context invalidated')) {
heartbeatErrorCount++;
if (heartbeatErrorCount === 1) {
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
}
clearInterval(heartbeatInterval);
observer.disconnect();
}
});
}
}, 15000);
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
observer.disconnect();
}
});
}
function schedulePeriodicHeartbeat() {
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
heartbeatTimeout = setTimeout(() => {
sendHeartbeat();
schedulePeriodicHeartbeat();
}, HEARTBEAT_INTERVAL_VAL);
}
function scheduleProactiveHeartbeat() {
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
proactiveHeartbeatTimeout = setTimeout(() => {
sendHeartbeat();
schedulePeriodicHeartbeat(); // Reschedules the next periodic check to be exactly 15s from now
}, 500); // 500ms stabilization delay
}
// Initial Setup
setupListeners();
// Maintain a persistent keep-alive port connection to prevent background SW suspension
let keepAlivePort = null;
function connectKeepAlivePort() {
try {
if (chrome.runtime.id) {
keepAlivePort = chrome.runtime.connect({ name: 'keepAlive' });
keepAlivePort.onDisconnect.addListener(() => {
keepAlivePort = null;
setTimeout(connectKeepAlivePort, 1000);
});
}
} catch (_e) {
// Extension context invalidated or disabled
}
}
connectKeepAlivePort();
schedulePeriodicHeartbeat();
// Immediate heartbeat on injection — populate peer data without waiting 15s
setTimeout(() => sendHeartbeat(), 300);
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
if (chrome.runtime.lastError) return;
if (res && res.lobbyActive && res.expectedTitle) {
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
startLobbyPoll(res.expectedTitle);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.2.1",
"description": "Synchronize video playback across different tabs and users.",
"version": "1.9.0",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
"tabs",
@@ -17,25 +17,25 @@
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon128.png",
"48": "icons/icon128.png",
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://koalasync.shik3i.net/*"],
"js": ["bridge.js"],
"matches": [
"https://sync.koalastuff.net/*"
],
"js": [
"bridge.js"
],
"run_at": "document_start"
}
],
"icons": {
"16": "icons/icon128.png",
"48": "icons/icon128.png",
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+237 -50
View File
@@ -27,13 +27,49 @@
font-size: 14px;
}
.header-row {
display: flex;
align-items: center;
margin-bottom: 16px;
}
h1 {
font-size: 18px;
margin: 0 0 16px 0;
margin: 0;
color: var(--accent);
text-align: center;
letter-spacing: 1px;
text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 8px;
}
h1 img {
width: 24px;
height: 24px;
object-fit: contain;
border-radius: 4px;
}
.popup-version {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text-muted);
text-decoration: none;
opacity: 0.5;
transition: opacity 0.2s, color 0.2s;
}
.popup-version:hover {
opacity: 1;
color: var(--accent);
}
.popup-version svg {
width: 12px;
height: 12px;
display: block;
}
/* Tabs */
@@ -189,16 +225,110 @@
}
.invite-box input { flex: 1; font-size: 11px; }
.invite-box button { width: 40px; padding: 0; }
/* Toast Notifications */
#toast-container {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
.toast {
pointer-events: auto;
padding: 10px 16px;
margin-bottom: 6px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
max-width: 280px;
text-align: center;
animation: toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in 2.7s forwards;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.toast-success { background: var(--success); color: white; }
.toast-error { background: var(--error); color: white; }
.toast-info { background: var(--accent); color: white; }
.toast-warning { background: #f59e0b; color: white; }
@keyframes toastSlideIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastFadeOut {
from { opacity: 1; }
to { opacity: 0; transform: translateY(-10px); }
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateX(5px); }
to { opacity: 1; transform: translateX(0); }
}
.tab-active-animate {
animation: fadeSlideIn 0.15s ease-out forwards;
}
/* Toggle Switch CSS */
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #334155;
transition: .3s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: #94a3b8;
transition: .3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--accent);
}
input:checked + .slider:before {
transform: translateX(16px);
background-color: white;
}
</style>
</head>
<body>
<h1>KoalaSync</h1>
<div id="toast-container"></div>
<div class="header-row">
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="popup-version">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span id="popupVersion">v0.0.0</span>
</a>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="tab-room">Room</button>
<button class="tab-btn" data-tab="tab-sync">Sync</button>
<button class="tab-btn" data-tab="tab-settings">Settings</button>
<button class="tab-btn" data-tab="tab-dev">Dev</button>
<button class="tab-btn active" data-tab="tab-room" title="Room settings and connection">Room</button>
<button class="tab-btn" data-tab="tab-sync" title="Video sync controls and remote actions">Sync</button>
<button class="tab-btn" data-tab="tab-settings" title="Extension preferences">Settings</button>
<button class="tab-btn" data-tab="tab-dev" title="Advanced Diagnostics & Logs">Status</button>
</div>
<!-- Room Tab -->
@@ -206,47 +336,52 @@
<!-- JOIN SECTION: Visible when not in a room -->
<div id="section-join">
<button id="createRoomBtn" class="primary" style="margin-bottom: 16px; padding: 14px; font-size: 15px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);">+ Create New Room</button>
<details style="margin-bottom: 12px; background: var(--card); border: 1px solid #334155; border-radius: 8px; padding: 8px;">
<summary style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; cursor: pointer; outline: none;">Manual Connect / Advanced</summary>
<div style="margin-top: 12px;">
<div class="form-group">
<label>Server</label>
<label title="Select which KoalaSync server to use">Server</label>
<div style="display:flex; gap:4px; margin-bottom:8px;">
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;">Official</button>
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;">Custom</button>
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;" title="Use the official reliable server">Official</button>
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" title="Connect to your own self-hosted server">Custom</button>
</div>
<input type="text" id="serverUrl" placeholder="wss://your-server:3000" style="display:none;">
</div>
<div class="form-group">
<label>Room ID</label>
<input type="text" id="roomId" placeholder="Leave empty to create">
<label title="The unique identifier for your sync room">Room ID</label>
<input type="text" id="roomId" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
</div>
<div class="form-group">
<label>Password (Optional)</label>
<input type="password" id="password" placeholder="Room password">
<label title="Optional password to restrict room access">Password (Optional)</label>
<input type="password" id="password" placeholder="Room Password (optional)" title="Password for the room (leave empty if none)">
</div>
<div id="roomError" style="display:none; color:var(--error); font-size:11px; margin-bottom:8px; text-align:center;"></div>
<button id="joinBtn" class="primary">Join Room</button>
<button id="createRoomBtn" class="secondary" style="border: 1px solid var(--accent); color: var(--accent);">Create Random Room</button>
<button id="joinBtn" class="primary" title="Connect to the room">Join Room</button>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem; margin-bottom: 8px;">
<label style="margin:0;">Public Rooms</label>
<button id="refreshRooms" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">REFRESH</button>
<label style="margin:0;" title="List of publicly available rooms on this server">Public Rooms</label>
<button id="refreshRooms" class="secondary" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" title="Refresh the list of public rooms">REFRESH</button>
</div>
<div id="publicRooms" class="info-card" style="max-height: 120px; overflow-y: auto; padding: 4px;">
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;">Refreshing...</div>
</div>
</div>
</details>
</div>
<!-- ACTIVE SECTION: Visible when in a room -->
<div id="section-active" style="display:none;">
<div class="info-card" style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; border-left: 4px solid var(--accent);">
<div>
<label style="margin-bottom: 0;">Active Room</label>
<label style="margin-bottom: 0;" title="The room you are currently connected to">Active Room</label>
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;">NONE</div>
</div>
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Official Server</div>
</div>
<div class="info-card" style="margin-bottom: 20px;">
<label>Invite Link</label>
<label title="Share this link with friends so they can join">Invite Link</label>
<div class="invite-box">
<input type="text" id="inviteLink" readonly>
<button id="copyInvite" class="secondary">📋</button>
@@ -254,7 +389,7 @@
</div>
<div style="margin-bottom: 20px;">
<label>Peers in Room</label>
<label title="Other users currently connected to this room">Peers in Room</label>
<div id="peerList" class="info-card">
<div style="text-align:center; color: var(--text-muted); font-size: 12px;">No peers connected</div>
</div>
@@ -266,25 +401,36 @@
<!-- Sync Tab -->
<div id="tab-sync" class="tab-content">
<!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active">
<div class="form-group">
<label>Target Tab (Video Source)</label>
<label title="Choose the browser tab containing the video to sync">Select Video</label>
<select id="targetTab">
<option value="">-- Select a Tab --</option>
</select>
</div>
<label>Remote Control</label>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="margin: 0;">Remote Control</label>
<button id="syncTabCopyInvite" title="Copy Invite Link" style="background:transparent; border: 1px solid #334155; border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor:pointer; opacity:0.8; transition: opacity 0.2s; color: var(--text-muted); display: flex; align-items: center; gap: 4px; white-space: nowrap;">📋 Invite Link</button>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<button id="playBtn" class="primary" style="flex:1; background: var(--success);">▶ Play</button>
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);">⏸ Pause</button>
<button id="playBtn" class="primary" style="flex:1; background: var(--success);" title="Send a Play command to everyone">▶ Play</button>
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);" title="Send a Pause command to everyone">⏸ Pause</button>
</div>
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); width: 100%; margin-bottom: 15px;">⚡ Force Sync Everyone</button>
<div style="display: flex; gap: 8px; margin-bottom: 15px; align-items: stretch;">
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); flex: 1;" title="Force all users to sync up">⚡ SYNC</button>
<select id="forceSyncMode" style="flex: 1; background: var(--card); border: 1px solid #334155; color: white; padding: 10px 8px; border-radius: 8px; font-size: 11px; font-family: inherit; cursor: pointer; align-self: stretch;" title="Choose sync target">
<option value="jump-to-others">Jump to Others</option>
<option value="jump-to-me">Jump to Me</option>
</select>
</div>
<!-- NEW: Last Action Status Card -->
<label>Last Activity Status</label>
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; min-height: 70px;">
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding-top: 20px;">No recent commands</div>
<label title="Shows the most recent play, pause, or seek command">Last Activity Status</label>
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; max-height: 120px; overflow-y: auto;">
<div style="text-align:center; color: var(--text-muted); font-size: 10px;">No recent commands</div>
</div>
<!-- Episode Auto-Sync Lobby Status -->
@@ -298,63 +444,90 @@
</div>
<div id="peerListSync" class="info-card" style="display:none;"></div>
</div>
<!-- SYNC INACTIVE: Visible when not in a room -->
<div id="sync-inactive" style="display:none; text-align: center; padding: 20px 10px;">
<div style="font-size: 32px; margin-bottom: 12px;">🔒</div>
<h3 style="margin: 0 0 8px 0; color: var(--accent); font-size: 15px;">Connect to a room first</h3>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 20px;">You need to join a room via an invite link or create a new one to sync videos.</p>
<button id="syncTabCreateRoomBtn" class="primary" style="padding: 12px; font-size: 14px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);" title="Create a new random room and join it">Create New Room</button>
</div>
</div>
<!-- Settings Tab -->
<div id="tab-settings" class="tab-content">
<div class="form-group">
<label>Your Username</label>
<input type="text" id="username" placeholder="Anonymous Koala" maxlength="20">
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;" title="Username helps others identify you.">Your Username</label>
<input type="text" id="username" placeholder="Anonymous Koala" maxlength="20" style="width: 150px;">
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;">Filter Noise Tabs</label>
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
<label style="margin-bottom: 0; cursor: help;" title="Filters out non-video tabs and unrelated domains to keep the list clean">Hide Clutter Tabs</label>
<label class="toggle-switch">
<input type="checkbox" id="filterNoise" checked>
<span class="slider"></span>
</label>
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
<label style="margin-bottom: 0; cursor: help;" title="Pauses automatically and waits for all peers when an episode changes, then sync-starts together.">Auto-Sync Next Episode</label>
<label class="toggle-switch">
<input type="checkbox" id="autoSyncNextEpisode">
<span class="slider"></span>
</label>
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0; cursor: help;" title="Automatically copies the invite link to your clipboard when creating a new room.">Auto-Copy Invite Link</label>
<label class="toggle-switch">
<input type="checkbox" id="autoCopyInvite" checked>
<span class="slider"></span>
</label>
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0; cursor: help;" title="Shows native system notifications when someone joins/leaves or plays/pauses.">Browser Notifications</label>
<label class="toggle-switch">
<input type="checkbox" id="browserNotifications">
<span class="slider"></span>
</label>
</div>
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
<p>• Username helps others identify you.</p>
<p>• Noise filtering uses a blacklist to hide common non-video sites (e.g. Search, Social Media) from the Target Tab selector.</p>
<p>• Auto-Sync will pause and wait for all peers when an episode changes, then sync-start together.</p>
</div>
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
<label>Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;">Regenerate Peer ID</button>
<label title="Tools for fixing connection issues">Troubleshooting</label>
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect">Regenerate Peer ID</button>
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;">Use this if you see "Duplicate Identity" errors.</p>
</div>
</div>
<!-- Dev Tab -->
<div id="tab-dev" class="tab-content">
<label>Connection Status</label>
<label title="Current WebSocket connection state">Connection Status</label>
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
<span id="connDot" class="status-dot status-offline"></span>
<span id="connText" style="flex:1;">Disconnected</span>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;">Copy Logs</button>
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server">RETRY</button>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing">Copy Logs</button>
</div>
<label>Video Debug Info</label>
<label title="Technical details about the currently selected video element">Video Debug Info</label>
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;">
No tab selected or video detected.
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<label>Full Action History</label>
<label title="Chronological log of all sync commands in the room">Full Action History</label>
</div>
<div id="historyList" class="info-card" style="max-height: 120px; overflow-y: auto; font-size: 10px; margin-bottom: 15px;">
<div style="text-align:center; color: var(--text-muted); font-size: 11px;">No activity yet</div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<label>Logs (Last 50)</label>
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
<label title="Technical connection logs for debugging">Logs (Last 50)</label>
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" title="Clear log output">CLEAR</button>
</div>
<div id="logList"></div>
@@ -365,5 +538,19 @@
</div>
<script src="popup.js" type="module"></script>
<!-- Onboarding Overlay -->
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter: blur(2px); z-index:1000; align-items:flex-end; justify-content:center; padding-bottom: 20px;">
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; width:90%; text-align:center; box-shadow:0 8px 32px rgba(0,0,0,0.5); ">
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">\u{1F44B}</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">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step">Next</button>
</div>
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
</div>
</div>
</body>
</html>
+708 -184
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
# ⚠️ READ BEFORE EDITING
This directory is a **MIRROR** of the root `/shared` folder.
**DO NOT edit these files directly.** Any changes made here will be overwritten the next time the synchronization script is run.
### Proper Workflow:
1. **Edit** the source files in the root `[repo_root]/shared/` directory.
2. **Run** the synchronization script:
- **Windows**: `[repo_root]\scripts\sync-constants.bat`
- **Linux/macOS**: `[repo_root]/scripts/sync-constants.sh`
3. **Verify** that the changes have propagated to this folder.
Failure to follow this protocol will result in out-of-sync components and broken protocol logic.
+1987
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "koalasync",
"version": "1.9.0",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
"build:extension": "node scripts/build-extension.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"devDependencies": {
"archiver": "^7.0.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0"
}
}
+33
View File
@@ -0,0 +1,33 @@
# Development Scripts
This directory contains utility scripts for the KoalaSync development workflow.
## build-extension.js
The primary build tool for KoalaSync. This Node.js script automates two critical tasks:
1. **Protocol Synchronization**: Copies the "Single Source of Truth" constants (`shared/constants.js`) and the domain blacklist (`shared/blacklist.js`) from the root `/shared` directory into the `extension/shared/` directory.
2. **Content Script Injection**: Injects protocol constants directly into `content.js` using marker-based replacement. This is necessary because `content.js` executes synchronously and cannot use ES module imports.
3. **Artifact Generation**: Compiles the extension into browser-specific bundles for Chrome and Firefox, located in the `dist/` directory.
### Usage
From the **repository root**, run:
```bash
node scripts/build-extension.js
```
### Why this script exists
KoalaSync uses **Vanilla JS** in the extension to maintain zero runtime dependencies and maximum privacy. Since we don't use a bundler (like Webpack or Vite) inside the extension, this script serves as our lightweight "pre-build" step to ensure that the protocol constants remain synchronized between the extension and the relay server.
### Content Injection Markers
The build script uses marker comments in `content.js` to locate and replace constant blocks:
| Marker Pair | Injected Value | Source |
|:---|:---|:---|
| `SHARED_EVENTS_INJECT_START` / `END` | The full `EVENTS` object | `shared/constants.js` |
| `SHARED_HEARTBEAT_INJECT_START` / `END` | `HEARTBEAT_INTERVAL` value | `shared/constants.js` |
> **⚠️ Do NOT remove or modify these marker comments in `content.js`.** They are required for the build script to function. If the markers are missing, the build will fail with a clear error message.
+180
View File
@@ -0,0 +1,180 @@
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const rootDir = path.join(__dirname, '..');
const extDir = path.join(rootDir, 'extension');
const distDir = path.join(rootDir, 'dist');
const baseManifestPath = path.join(extDir, 'manifest.base.json');
// Ensure dist directory exists
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}
fs.mkdirSync(distDir, { recursive: true });
// Sync shared constants from root /shared to /extension/shared
console.log('Syncing protocol constants...');
const masterSharedDir = path.join(rootDir, 'shared');
const extSharedDir = path.join(extDir, 'shared');
if (!fs.existsSync(extSharedDir)) {
fs.mkdirSync(extSharedDir, { recursive: true });
}
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md'];
for (const file of sharedFiles) {
const src = path.join(masterSharedDir, file);
const dest = path.join(extSharedDir, file);
if (!fs.existsSync(src)) {
throw new Error(`CRITICAL: Source shared file missing: ${src}. Aborting build to prevent broken artifacts.`);
}
fs.copyFileSync(src, dest);
}
console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to extension/shared/');
// Read the base manifest
const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
// Helper to copy files, ignoring manifest.json and manifest.base.json
// Also injects shared constants into content.js
function copyExtensionFiles(targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
// Read master constants for injection
const masterConstantsPath = path.join(rootDir, 'shared', 'constants.js');
const constantsContent = fs.readFileSync(masterConstantsPath, 'utf8');
// Robust Extraction using flexible regex
const eventsMatch = constantsContent.match(/export const EVENTS\s*=\s*({[\s\S]+?});/);
const heartbeatMatch = constantsContent.match(/export const HEARTBEAT_INTERVAL\s*=\s*(\d+);/);
if (!eventsMatch) {
throw new Error('CRITICAL: Could not find EVENTS object in shared/constants.js');
}
if (!heartbeatMatch) {
throw new Error('CRITICAL: Could not find HEARTBEAT_INTERVAL in shared/constants.js');
}
const eventsObject = eventsMatch[1];
const heartbeatVal = heartbeatMatch[1];
const items = fs.readdirSync(extDir);
for (const item of items) {
if (item === 'manifest.json' || item === 'manifest.base.json') continue;
const srcPath = path.join(extDir, item);
const destPath = path.join(targetDir, item);
if (fs.lstatSync(srcPath).isDirectory()) {
fs.cpSync(srcPath, destPath, { recursive: true });
} else {
if (item === 'content.js') {
// Perform injection
let content = fs.readFileSync(srcPath, 'utf8');
// 1. Inject Events
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}`;
if (ePattern.test(content)) {
content = content.replace(ePattern, eRep);
} else {
console.warn('⚠️ WARNING: Event markers not found in content.js');
}
// 2. Inject Heartbeat
const hStart = '// --- SHARED_HEARTBEAT_INJECT_START ---';
const hEnd = '// --- SHARED_HEARTBEAT_INJECT_END ---';
const hPattern = new RegExp(`${hStart}[\\s\\S]+?${hEnd}`);
const hRep = `${hStart}\n const HEARTBEAT_INTERVAL_VAL = ${heartbeatVal};\n ${hEnd}`;
if (hPattern.test(content)) {
content = content.replace(hPattern, hRep);
} else {
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
}
fs.writeFileSync(destPath, content);
console.log('✓ Injected shared constants into content.js');
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
}
// Helper to zip a directory
function zipDirectory(sourceDir, outPath) {
return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const stream = fs.createWriteStream(outPath);
archive
.directory(sourceDir, false)
.on('error', err => reject(err))
.pipe(stream);
stream.on('close', () => resolve());
archive.finalize();
});
}
async function buildBrowser(browserName, manifestModifier) {
console.log(`Building for ${browserName}...`);
const browserDistDir = path.join(distDir, browserName);
// 1. Copy files
copyExtensionFiles(browserDistDir);
// 2. Modify and write manifest
const browserManifest = manifestModifier(JSON.parse(JSON.stringify(baseManifest)));
fs.writeFileSync(
path.join(browserDistDir, 'manifest.json'),
JSON.stringify(browserManifest, null, 2)
);
// 3. Zip it
const zipPath = path.join(distDir, `koalasync-${browserName}.zip`);
await zipDirectory(browserDistDir, zipPath);
console.log(`Successfully built and zipped ${browserName} -> ${zipPath}`);
}
async function run() {
try {
// Build Chrome
await buildBrowser('chrome', (manifest) => {
manifest.background = {
service_worker: "background.js",
type: "module"
};
return manifest;
});
// Build Firefox
await buildBrowser('firefox', (manifest) => {
manifest.background = {
scripts: ["background.js"],
type: "module"
};
manifest.browser_specific_settings = {
gecko: {
id: "koalasync@koalastuff.net",
data_collection_permissions: {
required: ["none"]
}
}
};
return manifest;
});
console.log('Build complete!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
run();
-12
View File
@@ -1,12 +0,0 @@
@echo off
REM KoalaSync - Protocol Synchronization Script (Windows)
REM
REM This script copies the master constants.js file from the shared directory
REM to the extension directory. Since Chrome Extensions cannot load files
REM outside their root, this manual sync is required after any changes to
REM the shared protocol.
if not exist extension\shared mkdir extension\shared
copy /y shared\constants.js extension\shared\constants.js
copy /y shared\blacklist.js extension\shared\blacklist.js
echo ✓ constants.js and blacklist.js synced to extension\shared\
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
# KoalaSync - Protocol Synchronization Script (Linux/macOS)
#
# This script copies the master constants.js file from the shared directory
# to the extension directory. Since Chrome Extensions cannot load files
# outside their root, this manual sync is required after any changes to
# the shared protocol.
mkdir -p extension/shared
cp shared/constants.js extension/shared/constants.js
cp shared/blacklist.js extension/shared/blacklist.js
echo "✓ constants.js and blacklist.js synced to extension/shared/"
+13 -8
View File
@@ -1,11 +1,11 @@
# KoalaSync Relay Server
A high-performance Node.js relay server for synchronized video playback.
A Node.js relay server for synchronized video playback.
## Key Features
- **Zero-Persistence**: No database. All state is held in RAM.
- **Privacy First**: No tracking, no logging of user data.
- **WebSocket Only**: High performance with minimal overhead.
- **Privacy First**: No tracking, no persistent logging of user data.
- **WebSocket Only**: Minimal overhead with efficient transport.
## Setup
@@ -13,17 +13,21 @@ A high-performance Node.js relay server for synchronized video playback.
Copy `.env.example` to `.env` and configure your settings.
```bash
PORT=3000
MAX_ROOMS=100
MAX_PEERS_PER_ROOM=20
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=50
MIN_VERSION=1.0.0
```
### Docker (Recommended)
The server is designed to run in a Docker container.
The server is available as a pre-built image on GHCR.
```bash
# Build from the repository root
# Pull from GHCR
docker pull ghcr.io/shik3i/koalasync:latest
# Or build from the repository root
docker build -t koala-sync-server -f server/Dockerfile .
```
See [docker-compose.example.yml](../docker-compose.example.yml) in the root directory for a ready-to-use configuration.
### Manual Setup
```bash
@@ -34,5 +38,6 @@ npm start
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
- **Token Handshake**: Requires a valid token defined in `shared/constants.js`.
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
+278 -91
View File
@@ -3,7 +3,7 @@ import { createServer } from 'http';
import { Server } from 'socket.io';
import bcrypt from 'bcryptjs';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN } from '../shared/constants.js';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
dotenv.config();
@@ -15,18 +15,45 @@ const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
const app = express();
app.set('trust proxy', 1); // For real client IP through reverse proxy
// Health Check
app.get('/', (req, res) => res.json({ status: 'online', service: 'KoalaSync Relay' }));
// Health Check with Rate Limiting
app.get('/', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Too many requests. Try again later.' });
}
res.json({ status: 'online', service: 'KoalaSync Relay' });
});
app.get('/health', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
res.json({
status: 'ok',
uptime: process.uptime(),
rooms: rooms.size,
connections: io.engine?.clientsCount ?? 0,
timestamp: Date.now()
});
});
const httpServer = createServer(app);
// Socket.IO setup with security constraints
const io = new Server(httpServer, {
cors: {
origin: ["https://koalasync.shik3i.net"],
origin: (origin, callback) => {
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
callback(null, true);
} else {
log('CORS', `Rejected origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
}
},
methods: ["GET", "POST"]
},
maxHttpBufferSize: 1024, // 1KB max per message
maxHttpBufferSize: 4096, // 4KB max per message (headroom for JOIN_ROOM payloads)
transports: ['websocket'],
allowUpgrades: false
});
@@ -37,6 +64,7 @@ const io = new Server(httpServer, {
const rooms = new Map();
const socketToRoom = new Map();
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
function log(type, message, details = '') {
const timestamp = new Date().toISOString();
@@ -66,6 +94,10 @@ function checkAuthRate(ip, roomId) {
}
function recordAuthFailure(ip, roomId) {
if (failedAuthAttempts.size > 50000) {
failedAuthAttempts.clear();
log('SECURITY', 'Cleared failedAuthAttempts map to prevent memory leak');
}
const key = `${ip}:${roomId}`;
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
record.count++;
@@ -73,19 +105,20 @@ function recordAuthFailure(ip, roomId) {
failedAuthAttempts.set(key, record);
}
// Periodically clean up old auth failure records (every hour)
// Periodically clean up old auth failure records (every 15 minutes)
setInterval(() => {
const now = Date.now();
for (const [key, record] of failedAuthAttempts.entries()) {
if (now - record.lastAttempt > 60 * 60 * 1000) {
if (now - record.lastAttempt > 15 * 60 * 1000) {
failedAuthAttempts.delete(key);
}
}
}, 60 * 60 * 1000);
}, 15 * 60 * 1000);
const eventCounts = new Map(); // socketId -> { count, resetTime }
const healthCounts = new Map(); // ip -> { count, resetTime }
// Clean up connection counts to prevent memory leak
// Clean up connection counts and event counts to prevent memory leak
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of connectionCounts.entries()) {
@@ -93,6 +126,16 @@ setInterval(() => {
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);
}
}
}, 60000);
function checkConnectionRate(ip) {
@@ -113,6 +156,15 @@ function checkEventRate(socketId) {
return entry.count <= 30;
}
function checkHealthRate(ip) {
const now = Date.now();
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
healthCounts.set(ip, entry);
return entry.count <= 60;
}
/**
* Central peer teardown. Removes a socket from all room state and notifies
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
@@ -120,12 +172,8 @@ function checkEventRate(socketId) {
* @param {string} socketId - The socket.id being removed.
* @param {string} roomId - The room it belongs to.
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
* @param {boolean} [emitLeave=true] - Set false when the socket.io room leave
* is handled by the caller (e.g. reaper calls
* socket.leave() before us, or dedupe calls
* oldSocket.leave() before disconnecting).
*/
function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
function removePeerFromRoom(socketId, roomId, reason) {
const room = rooms.get(roomId);
if (!room) return;
@@ -141,13 +189,25 @@ function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
// 2. Remove from global maps
socketToRoom.delete(socketId);
if (peerToSocket.get(peerId) === socketId) {
const currentSocketId = peerToSocket.get(peerId);
if (currentSocketId === socketId) {
peerToSocket.delete(peerId);
}
// 3. Notify remaining peers (use io.to so the removed socket itself
// doesn't receive it — it has already left or is disconnecting)
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId);
if (!isPeerStillConnected) {
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
}
// 3.5. Clean up active lobby if a peer leaves
if (room.activeLobby) {
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
room.activeLobby = null; // Dissolve lobby
}
}
// 4. Delete empty room
if (room.peers.size === 0) {
@@ -159,7 +219,10 @@ function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
}
io.on('connection', (socket) => {
const clientIp = socket.handshake.address;
// Get real client IP behind proxy/CDN
const forwardedFor = socket.handshake.headers['x-forwarded-for'];
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : socket.handshake.address;
socket._clientIp = clientIp;
// 1. Connection Rate Limit
if (!checkConnectionRate(clientIp)) {
@@ -180,8 +243,21 @@ io.on('connection', (socket) => {
}
if (clientVersion) {
const [cMaj, cMin, cPatch] = clientVersion.split('.').map(Number);
if (typeof clientVersion !== 'string') {
log('AUTH', `Invalid version type from ${clientIp}`);
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
socket.disconnect(true);
return;
}
const parts = clientVersion.split('.').map(Number);
const cMaj = parts[0], cMin = parts[1], cPatch = parts[2] || 0;
const [mMaj, mMin, mPatch] = MIN_VERSION.split('.').map(Number);
if (isNaN(cMaj) || isNaN(cMin) || isNaN(cPatch)) {
log('AUTH', `Invalid version format (${clientVersion}) from ${clientIp}`);
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
socket.disconnect(true);
return;
}
const tooOld = cMaj < mMaj || (cMaj === mMaj && cMin < mMin) || (cMaj === mMaj && cMin === mMin && cPatch < mPatch);
if (tooOld) {
log('AUTH', `Version too old (${clientVersion}) from ${clientIp}`);
@@ -200,19 +276,21 @@ io.on('connection', (socket) => {
return;
}
if (!payload || typeof payload.roomId !== 'string') return;
const { password, peerId, protocolVersion } = payload;
// --- M-2: Sanitize and clamp all string fields ---
const roomId = String(payload.roomId || '').substring(0, 64);
// --- S-1 & S-5: Sanitize and clamp all incoming fields ---
const password = typeof payload.password === 'string' ? payload.password.substring(0, 128) : null;
const peerId = typeof payload.peerId === 'string' ? payload.peerId.substring(0, 16) : null;
const protocolVersion = typeof payload.protocolVersion === 'string' ? payload.protocolVersion.substring(0, 16) : null;
const roomId = String(payload.roomId || '').replace(/[^a-zA-Z0-9\-]/g, '').substring(0, 64);
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
if (!roomId) return; // Guard: empty after sanitization
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
try {
// Protocol check
if (protocolVersion !== '1.0.0') {
if (protocolVersion !== PROTOCOL_VERSION) {
log('AUTH', `Protocol mismatch from ${peerId}: ${protocolVersion}`);
socket.emit(EVENTS.ERROR, { message: 'Incompatible protocol version' });
return;
@@ -220,64 +298,102 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId) {
return; // Already in this room, ignore to prevent spam
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
return; // Already in this room with same peerId, ignore to prevent spam
}
if (oldMapping && oldMapping.roomId !== roomId) {
socket.leave(oldMapping.roomId);
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
}
const ip = socket.handshake.address;
const ip = socket._clientIp || socket.handshake.address;
if (!checkAuthRate(ip, roomId)) {
log('AUTH', `Auth rate limit blocked ${ip} from room ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Too many failed attempts. Try again later." });
return;
}
let room = rooms.get(roomId);
let createdByMe = false;
if (!room) {
if (rooms.size >= MAX_ROOMS) {
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
return;
// Acquire per-room creation lock to prevent race conditions
let lockPromise = roomCreationLocks.get(roomId);
if (lockPromise) {
await lockPromise;
room = rooms.get(roomId);
}
if (!room) {
// Create and store lock before async boundary
let resolveLock;
lockPromise = new Promise(resolve => { resolveLock = resolve; });
roomCreationLocks.set(roomId, lockPromise);
try {
if (rooms.size >= MAX_ROOMS) {
log('ROOM', `Server at capacity: ${rooms.size}/${MAX_ROOMS} rooms — rejecting join`);
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
return;
}
const passwordHash = password ? await bcrypt.hash(password, 10) : null;
room = {
passwordHash,
peers: new Set(),
peerIds: new Map(),
peerData: new Map(), // socketId -> { peerId, tabTitle }
lastActivity: Date.now()
};
rooms.set(roomId, room);
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
} else {
const passwordHash = password ? await bcrypt.hash(password, 10) : null;
room = {
passwordHash,
peers: new Set(),
peerIds: new Map(),
peerData: new Map(),
lastActivity: Date.now()
};
rooms.set(roomId, room);
createdByMe = true;
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
} finally {
roomCreationLocks.delete(roomId);
resolveLock();
}
}
}
if (!room) {
socket.emit(EVENTS.ERROR, { message: "Join error" });
return;
}
if (!createdByMe) {
if (room.passwordHash) {
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
recordAuthFailure(ip, roomId);
log('AUTH', `Invalid password from ${ip} for room ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Invalid password" });
return;
}
}
if (room.peers.size >= MAX_PEERS_PER_ROOM) {
log('ROOM', `Room full (${room.peers.size}/${MAX_PEERS_PER_ROOM}): ${roomId.substring(0, 3)}***`);
socket.emit(EVENTS.ERROR, { message: "Room full" });
return;
}
// Peer Deduplication: Remove existing socket for the same peerId
const dedupeSids = [];
for (const [sid, data] of room.peerData.entries()) {
if (data.peerId === peerId && sid !== socket.id) {
const oldSocket = io.sockets.sockets.get(sid);
if (oldSocket) {
oldSocket.emit(EVENTS.ERROR, { message: 'Deduplication: Another session with this ID joined. Disconnecting...' });
oldSocket.leave(roomId);
oldSocket.disconnect(true);
log('DEDUPE', `Kicked old session for peer ${peerId}`);
}
removePeerFromRoom(sid, roomId, 'dedupe');
dedupeSids.push(sid);
}
}
for (const sid of dedupeSids) {
// Re-check: the socket might have been replaced by another concurrent join
const currentMapping = room.peerData.get(sid);
if (!currentMapping || currentMapping.peerId !== peerId) continue;
const oldSocket = io.sockets.sockets.get(sid);
if (oldSocket) {
oldSocket.emit(EVENTS.ERROR, { message: 'Deduplication: Another session with this ID joined. Disconnecting...' });
oldSocket.leave(roomId);
oldSocket.disconnect(true);
log('DEDUPE', `Kicked old session for peer ${peerId}`);
}
removePeerFromRoom(sid, roomId, 'dedupe');
}
}
socket.join(roomId);
@@ -296,12 +412,15 @@ io.on('connection', (socket) => {
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
socket.emit(EVENTS.ROOM_DATA, {
roomId,
peers: Array.from(room.peers).map(sid => room.peerData.get(sid))
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
activeLobby: room.activeLobby || null
});
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
} catch (err) {
log('ERROR', `Join error for ${socket.id}`, err);
socket.emit(EVENTS.ERROR, { message: "Join error" });
if (socket.connected) {
socket.emit(EVENTS.ERROR, { message: "Join error" });
}
}
});
@@ -315,44 +434,90 @@ io.on('connection', (socket) => {
relayEvents.forEach(eventName => {
socket.on(eventName, (data) => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
socket.disconnect(true);
return;
}
try {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
socket.disconnect(true);
return;
}
if (!data || typeof data !== 'object') return; // Prevent null/invalid payload crash
if (!data || typeof data !== 'object') return;
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const room = rooms.get(mapping.roomId);
if (room) {
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const room = rooms.get(mapping.roomId);
if (room) {
room.lastActivity = Date.now();
// Update peer metadata and lastSeen
// Sanitize mutable string fields to enforce the same length
// limits as JOIN_ROOM — the relay path is otherwise unbounded.
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : val;
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
const validState = (val) => (val === 'playing' || val === 'paused') ? val : undefined;
const validBool = (val) => typeof val === 'boolean' ? val : undefined;
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
room.peerData.set(socket.id, {
...existing,
username: data.username !== undefined ? clamp(data.username, 30) : existing.username,
tabTitle: data.tabTitle !== undefined ? clamp(data.tabTitle, 100) : existing.tabTitle,
mediaTitle: data.mediaTitle !== undefined ? clamp(data.mediaTitle, 100) : existing.mediaTitle,
playbackState: data.playbackState !== undefined ? data.playbackState : existing.playbackState,
currentTime: data.currentTime !== undefined ? data.currentTime : existing.currentTime,
volume: data.volume !== undefined ? data.volume : existing.volume,
muted: data.muted !== undefined ? data.muted : existing.muted,
username: data.username !== undefined ? (clamp(data.username, 30) ?? existing.username) : existing.username,
tabTitle: data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle,
mediaTitle: data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle,
playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState,
currentTime: data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime,
volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume,
muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted,
lastSeen: Date.now()
});
socket.to(mapping.roomId).emit(eventName, { ...data, senderId: mapping.peerId });
// --- S-3: Construct clean relay payload — never forward raw client data ---
const relayPayload = {
senderId: mapping.peerId,
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
currentTime: clampNum(data.currentTime, 0, 86400),
targetTime: clampNum(data.targetTime, 0, 86400),
playbackState: validState(data.playbackState),
username: clamp(data.username, 30),
tabTitle: clamp(data.tabTitle, 100),
mediaTitle: clamp(data.mediaTitle, 100),
volume: clampNum(data.volume, 0, 1),
muted: validBool(data.muted),
peerId: typeof data.peerId === 'string' ? data.peerId.substring(0, 16) : undefined,
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
expectedTitle: clamp(data.expectedTitle, 100),
title: clamp(data.title, 100),
actionTimestamp: clampNum(data.actionTimestamp, 0, Number.MAX_SAFE_INTEGER),
};
// Strip undefined keys for clean wire format
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
socket.to(mapping.roomId).emit(eventName, relayPayload);
// --- Side-effects: Server-side Episode Lobby Tracking ---
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
room.activeLobby = {
expectedTitle: relayPayload.expectedTitle,
initiatorPeerId: mapping.peerId,
readyPeers: [mapping.peerId]
};
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
room.activeLobby.readyPeers.push(mapping.peerId);
}
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE) && room.activeLobby) {
room.activeLobby = null;
}
}
}
} catch (err) {
log('ERROR', `Relay handler error for ${eventName}: ${err.message}`);
}
});
});
socket.on(EVENTS.GET_ROOMS, () => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket (GET_ROOMS): ${socket.id}`);
socket.disconnect(true);
return;
}
const list = Array.from(rooms.entries()).map(([id, r]) => ({
id,
peerCount: r.peers.size,
@@ -369,23 +534,30 @@ io.on('connection', (socket) => {
}
});
socket.on(EVENTS.EVENT_ACK, (data) => {
if (!data.targetId) return;
const senderMapping = socketToRoom.get(socket.id);
const targetSocketId = peerToSocket.get(data.targetId);
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
socket.on(EVENTS.EVENT_ACK, (data) => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket (ACK): ${socket.id}`);
socket.disconnect(true);
return;
}
if (!data || typeof data !== 'object') return;
if (typeof data.targetId !== 'string') return;
if (data.actionTimestamp !== undefined && (typeof data.actionTimestamp !== 'number' || !Number.isFinite(data.actionTimestamp))) return;
const senderMapping = socketToRoom.get(socket.id);
const targetSocketId = peerToSocket.get(data.targetId);
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
// Security: Only relay ACK if both peers are in the same room
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
senderId: senderMapping.peerId,
actionTimestamp: data.actionTimestamp
});
} else {
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
}
});
// Security: Only relay ACK if both peers are in the same room
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
senderId: senderMapping.peerId,
actionTimestamp: data.actionTimestamp
});
} else {
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
@@ -405,7 +577,11 @@ setInterval(() => {
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
for (const [roomId, room] of rooms) {
// Snapshot room keys to avoid mutation during iteration
const roomIds = Array.from(rooms.keys());
for (const roomId of roomIds) {
const room = rooms.get(roomId);
if (!room) continue; // Room may have been deleted between snapshot and now
// 1. Prune dead peers
// Snapshot keys first — we must not mutate peerData while iterating it.
const staleSids = [];
@@ -417,14 +593,15 @@ setInterval(() => {
for (const sid of staleSids) {
// Gracefully evict the socket from the Socket.IO room if it is
// still technically connected (zombie with no heartbeat).
const deadSocket = io.sockets.sockets.get(sid);
const deadSocket = io.sockets?.sockets?.get(sid);
if (deadSocket) deadSocket.leave(roomId);
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
removePeerFromRoom(sid, roomId, 'reaper');
}
// 2. Prune empty or inactive rooms
if (room.peers.size === 0 || room.lastActivity < roomCutoff) {
const currentRoom = rooms.get(roomId);
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
rooms.delete(roomId);
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
@@ -455,3 +632,13 @@ function gracefulShutdown(signal) {
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
log('ERROR', `Unhandled rejection: ${reason}`);
process.exit(1);
});
+22 -12
View File
@@ -4,20 +4,30 @@ This directory contains constants and protocol definitions used by both the exte
## Syncing with the Extension
> [!IMPORTANT]
> Every time this file is modified, you must run `scripts/sync-constants.sh` to keep the extension's copy up to date.
> Every time this directory is modified, you must run `node scripts/build-extension.js` to keep the extension's copy up to date.
Because Chrome Extensions cannot load files outside their root directory, `constants.js` must be copied to `extension/shared/constants.js` whenever it is modified.
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.
## Security & Versioning Constants
- `OFFICIAL_SERVER_TOKEN`: A 32-byte hex token required to connect to the official relay server.
- `APP_VERSION`: The current version of the extension. Used by the server to enforce minimum version requirements (Revocation). This must always be in sync with `manifest.json`.
- `APP_VERSION`: The current version of the extension. Automatically injected from the git tag during CI release builds.
- `OFFICIAL_SERVER_URL`: The default endpoint for the official KoalaSync relay.
- `ROOM_DATA`: Server response with current room state (peers).
- `PLAY`: Sync command to start playback.
- `PAUSE`: Sync command to pause playback.
- `SEEK`: Sync command to change the current time.
- `PEER_STATUS`: Heartbeat or join/leave notification for peers.
- `FORCE_SYNC_PREPARE`: Phase 1 of Force Sync (Pause & Seek).
- `FORCE_SYNC_ACK`: Peer confirmation of Phase 1 readiness.
- `FORCE_SYNC_EXECUTE`: Phase 2 of Force Sync (Start Playback).
- `ERROR`: Generic error message from the server.
## Protocol Events
For the complete and current event list, see the `EVENTS` object in [`constants.js`](constants.js). Key events include:
| Event | Direction | Purpose |
|:------|:----------|:--------|
| `JOIN_ROOM` | Client → Server | Request to join a room with credentials |
| `LEAVE_ROOM` | Client → Server | Leave the current room |
| `ROOM_DATA` | Server → Client | Current room state (peers list) |
| `PLAY` / `PAUSE` / `SEEK` | Bidirectional relay | Media control commands |
| `PEER_STATUS` | Bidirectional relay | Heartbeat or join/leave notification |
| `FORCE_SYNC_PREPARE` | Bidirectional relay | Phase 1: Pause & seek to target time |
| `FORCE_SYNC_ACK` | Bidirectional relay | Phase 1 confirmation: peer is buffered |
| `FORCE_SYNC_EXECUTE` | Bidirectional relay | Phase 2: Resume playback simultaneously |
| `EVENT_ACK` | Server → Client | Delivery confirmation for UI feedback |
| `EPISODE_LOBBY` | Bidirectional relay | Episode transition: waiting for all peers |
| `EPISODE_READY` | Bidirectional relay | Episode confirmation: peer has loaded |
| `GET_ROOMS` / `ROOM_LIST` | Client ↔ Server | Room discovery |
| `ERROR` | Server → Client | Error message |
+83 -2
View File
@@ -2,7 +2,7 @@
* blacklist.js
*
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
* If you edit this file, you MUST run /scripts/sync-constants.bat
* If you edit this file, you MUST run: node scripts/build-extension.js
* to propagate changes to the extension and relay server.
*
* Domains to be filtered out from the tab selection dropdown to reduce "noise".
@@ -17,13 +17,37 @@ export const BLACKLIST_DOMAINS = [
'msn.com',
'baidu.com',
'yandex.ru',
'ecosia.org',
'startpage.com',
'search.brave.com',
'qwant.com',
'you.com',
'perplexity.ai',
'ask.com',
'search.yahoo.com',
'swisscows.ch',
'mojeek.com',
// Mail Providers
'mail.google.com',
'gmail.com',
'outlook.live.com',
'outlook.office.com',
'mail.yahoo.com',
'gmx.net',
'gmx.de',
'gmx.com',
'web.de',
'protonmail.com',
'proton.me',
't-online.de',
'posteo.de',
'mailbox.org',
'mail.de',
'zoho.com',
'fastmail.com',
'tutanota.com',
'mail.ru',
// Cloud Storage & Documents
'docs.google.com',
@@ -61,6 +85,13 @@ export const BLACKLIST_DOMAINS = [
'instagram.com',
'reddit.com',
'quora.com',
'threads.net',
'bsky.app',
'mastodon.social',
'vk.com',
'weibo.com',
'9gag.com',
'imgur.com',
// E-Commerce
'amazon.',
@@ -78,13 +109,63 @@ export const BLACKLIST_DOMAINS = [
'myanimelist.net',
// Development & Utilities
'timer.shik3i.net',
'koalastuff.net',
'auth.koalastuff.net',
'blog.koalastuff.net',
'clicker.koalastuff.net',
'cookies.koalastuff.net',
'multibox.koalastuff.net',
'snippets.koalastuff.net',
'status.koalastuff.net',
'sync.koalastuff.net',
'timer.koalastuff.net',
'localhost',
'zoom.us',
'teams.microsoft.com',
'meet.google.com',
'chrome.google.com',
// Music Streaming
'music.youtube.com',
'open.spotify.com',
'soundcloud.com',
'deezer.com',
'tidal.com',
// Knowledge & Blogs
'wikipedia.org',
'medium.com',
'dev.to',
'news.ycombinator.com',
// Design & Creative Tools
'figma.com',
'canva.com',
'miro.com',
// Online IDEs & Hosting
'vscode.dev',
'replit.com',
'codesandbox.io',
'vercel.com',
'netlify.com',
// Social & Image Sharing
'pinterest.com',
'tumblr.com',
// Language Learning
'duolingo.com',
'hellotalk.com',
// Google Utilities
'calendar.google.com',
'keep.google.com',
// Finance & Payments
'paypal.com',
'stripe.com',
// Games & Idle Sites
'milkywayidle.com',
'melvoridle.com',
+5 -5
View File
@@ -2,15 +2,15 @@
* KoalaSync Shared Constants & Protocol Definitions
*
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
* If you edit this file, you MUST run /scripts/sync-constants.bat
* If you edit this file, you MUST run: node scripts/build-extension.js
* to propagate changes to the extension and relay server.
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.2.1";
export const APP_VERSION = "1.9.0";
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
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 EVENTS = {
@@ -40,5 +40,5 @@ export const EVENTS = {
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
+281
View File
@@ -0,0 +1,281 @@
/**
* 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
* to propagate changes to the extension.
*
* The emoji map covers every animal/creature that has a Unicode emoji.
* Entries are sorted by key length (longest first) at lookup time to
* prevent substring false-matches (e.g. "caterpillar" must be checked
* before "cat").
*
* If you add a new animal noun to USERNAME_NOUNS, ensure it has a
* corresponding entry in ANIMAL_EMOJI_MAP (or a substring that already
* maps to a suitable emoji).
*/
export const USERNAME_ADJECTIVES = [
'Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy',
'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty',
'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden',
'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'
];
export const USERNAME_NOUNS = [
'Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion',
'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon',
'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis',
'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose',
'Specter',
'Cat', 'Dog', 'Deer', 'Bat', 'Gorilla', 'Monkey', 'Rabbit',
'Horse', 'Unicorn', 'Zebra', 'Leopard', 'Cheetah', 'Puma',
'Ram', 'Goat', 'Bull', 'Donkey', 'Moose',
'Elephant', 'Giraffe', 'Hippo', 'Sloth', 'Kangaroo',
'Raccoon', 'Hamster', 'Hedgehog', 'Skunk', 'Beaver', 'Bison',
'Camel', 'Llama', 'Hyena', 'Coyote',
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
'Jellyfish', 'Turtle',
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
'Beetle', 'Snail', 'Dragonfly', 'Caterpillar',
'Alien', 'Robot', 'Mermaid', 'Ghoul', 'Sprite', 'Cyborg',
'Dinosaur', 'Reaper', 'Wraith', 'Sphinx',
];
export const ANIMAL_EMOJI_MAP = {
'hippopotamus': '🦛',
'rhinoceros': '🦏',
'caterpillar': '🐛',
'chimpanzee': '🐵',
'orangutan': '🦧',
'blackbird': '🐦‍⬛',
'bumblebee': '🐝',
'cockatoo': '🦜',
'cockroach': '🪳',
'dragonfly': '🐉',
'grasshopper': '🦗',
'hedgehog': '🦔',
'jellyfish': '🪼',
'kangaroo': '🦘',
'ladybird': '🐞',
'ladybug': '🐞',
'porcupine': '🦔',
'scorpion': '🦂',
'tarantula': '🕷️',
'alligator': '🐊',
'anaconda': '🐍',
'antelope': '🦌',
'blowfish': '🐡',
'butterfly': '🦋',
'chameleon': '🦎',
'chipmunk': '🐿️',
'crocodile': '🐊',
'dinosaur': '🦖',
'elephant': '🐘',
'flamingo': '🦩',
'giraffe': '🦒',
'hamster': '🐹',
'leopard': '🐆',
'lobster': '🦞',
'mermaid': '🧜‍♀️',
'mongoose': '🦦',
'mosquito': '🦟',
'pangolin': '🦔',
'peacock': '🦚',
'penguin': '🐧',
'phoenix': '🐦‍🔥',
'raccoon': '🦝',
'seahorse': '🐴',
'sealion': '🦭',
'unicorn': '🦄',
'vampire': '🦇',
'warthog': '🐗',
'wolverine': '🦡',
'mammoth': '🦣',
'meerkat': '🦦',
'octopus': '🐙',
'opposum': '🐭',
'ostrich': '🐦',
'panther': '🐆',
'pelican': '🦩',
'rooster': '🐓',
'serpent': '🐍',
'specter': '👻',
'spectre': '👻',
'sparrow': '🐦',
'spider': '🕷️',
'sphinx': '🦁',
'squirrel': '🐿️',
'stingray': '🦈',
'termite': '🐜',
'tortoise': '🐢',
'turkey': '🦃',
'walrus': '🦭',
'wombat': '🦡',
'woodpecker': '🐦',
'alien': '👾',
'badger': '🦡',
'beaver': '🦫',
'beetle': '🪲',
'beluga': '🐋',
'bison': '🦬',
'bobcat': '🐱',
'buffalo': '🦬',
'bunny': '🐰',
'camel': '🐪',
'cheetah': '🐆',
'chicken': '🐔',
'cobra': '🐍',
'condor': '🦅',
'cougar': '🐆',
'coyote': '🐺',
'crane': '🦩',
'cricket': '🦗',
'crow': '🐦‍⬛',
'cyborg': '🤖',
'dolphin': '🐬',
'donkey': '🫏',
'dragon': '🐉',
'drake': '🐉',
'eagle': '🦅',
'falcon': '🦅',
'ferret': '🦦',
'gazelle': '🦌',
'gecko': '🦎',
'gerbil': '🐹',
'ghost': '👻',
'ghoul': '👻',
'goose': '🪿',
'gopher': '🐹',
'gorilla': '🦍',
'grizzly': '🐻',
'heron': '🦩',
'hippo': '🦛',
'hornet': '🐝',
'hyena': '🐺',
'iguana': '🦎',
'jackal': '🐺',
'jaguar': '🐆',
'kitten': '🐱',
'koala': '🐨',
'lemur': '🐒',
'lizard': '🦎',
'llama': '🦙',
'locust': '🦗',
'lynx': '🐱',
'macaw': '🦜',
'mantis': '🦗',
'mink': '🦦',
'monkey': '🐵',
'moose': '🦌',
'mouse': '🐭',
'orca': '🐋',
'otter': '🦦',
'oyster': '🦪',
'panda': '🐼',
'parrot': '🦜',
'pigeon': '🕊️',
'polar': '🐻‍❄️',
'poodle': '🐩',
'puffin': '🐧',
'puma': '🐆',
'rabbit': '🐰',
'raptor': '🦖',
'raven': '🐦‍⬛',
'reaper': '👻',
'rhino': '🦏',
'robin': '🐦',
'robot': '🤖',
'salmon': '🐟',
'shrimp': '🦐',
'skunk': '🦨',
'sloth': '🦥',
'snail': '🐌',
'snake': '🐍',
'sprite': '🧚',
'squid': '🦑',
'swan': '🦢',
'tapir': '🐗',
'tiger': '🐯',
'toad': '🐸',
'trout': '🐟',
'tuna': '🐟',
'turtle': '🐢',
'viper': '🐍',
'vulture': '🦅',
'weasel': '🦦',
'whale': '🐋',
'wolf': '🐺',
'wraith': '👻',
'zebra': '🦓',
'ape': '🦍',
'ant': '🐜',
'bat': '🦇',
'bee': '🐝',
'bug': '🐛',
'cat': '🐱',
'cow': '🐮',
'crab': '🦀',
'dog': '🐶',
'duck': '🦆',
'elk': '🦌',
'fly': '🪰',
'fox': '🦊',
'frog': '🐸',
'goat': '🐐',
'hawk': '🦅',
'hen': '🐔',
'hog': '🐷',
'lion': '🦁',
'mole': '🐭',
'moth': '🦋',
'mule': '🫏',
'owl': '🦉',
'pig': '🐷',
'ram': '🐏',
'rat': '🐀',
'seal': '🦭',
'shark': '🦈',
'wasp': '🐝',
'yak': '🐂',
'doe': '🦌',
'ewe': '🐑',
'buck': '🦌',
'ox': '🐂',
'bull': '🐂',
'dodo': '🦤',
'boar': '🐗',
'bear': '🐻',
'deer': '🦌',
'dove': '🕊️',
'fish': '🐟',
'hare': '🐰',
'horse': '🐴',
'lamb': '🐑',
'mare': '🐴',
'pony': '🐴',
'pup': '🐶',
'croc': '🐊',
'gnat': '🦟',
'gnu': '🦬',
};
export function getAvatarForName(username) {
if (!username) return '\u{1F464}';
const lower = username.toLowerCase();
const sorted = Object.entries(ANIMAL_EMOJI_MAP).sort((a, b) => b[0].length - a[0].length);
for (const [key, emoji] of sorted) {
if (lower.includes(key)) return emoji;
}
return '\u{1F464}';
}
export function generateUsername() {
const adj = USERNAME_ADJECTIVES[Math.floor(Math.random() * USERNAME_ADJECTIVES.length)];
const noun = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)];
return `${adj}${noun}`;
}
+38 -27
View File
@@ -1,33 +1,50 @@
# KoalaSync Landing Page
# KoalaSync Website & Invitation Bridge
This directory contains the static marketing website for KoalaSync. It is built using vanilla HTML, CSS, and JavaScript to ensure maximum performance, zero tracking, and easy hosting.
This directory contains the KoalaSync website. It serves a dual purpose: it is both the **marketing landing page** and the **technical bridge** for joining synchronized rooms.
## Features
- **Privacy First**: No external fonts, scripts, or trackers.
- **Modern Tech Aesthetic**: Pure CSS animated gradients and glassmorphism.
- **Smart Join**: Integrated bridge for communication with the KoalaSync browser extension.
## Core Roles
### 1. Marketing & Onboarding
Provides a premium, bilingual (EN/DE) 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:
- **Detects the Extension**: Verifies if KoalaSync is installed via the `bridge.js` content script.
- **Privacy-First Handshake**: The room credentials (ID/Password) are stored in the **URL Hash (#)**. This ensures the sensitive credentials **never reach the web server** and are processed entirely within the user's browser.
- **Auto-Join**: If the extension is detected, it automatically triggers the join flow without requiring user input.
## Architecture
The website is 100% **Static HTML, CSS, and JS**.
- **Zero Backend**: No Node.js, PHP, or databases are required to host the 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.
## Hosting with Caddy
Caddy is the recommended web server for KoalaSync due to its automatic HTTPS and simple configuration.
Caddy is the recommended web server. It provides automatic HTTPS and high-performance static file serving.
### Example Caddyfile
### Recommended Caddyfile
To host the website on `koalasync.shik3i.net`, you can use the following configuration:
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../Caddyfile.example).
```caddy
koalasync.shik3i.net {
# Path to the website directory
sync.koalastuff.net {
root * /var/www/koalasync/website
# Enable static file serving
file_server
# Enable Gzip/Zstd compression
encode zstd gzip
# Security Headers
# Static Caching for high-performance PageSpeed (1 year with validation)
@static {
file
path *.ico *.css *.js *.png *.svg *.webp
}
header @static Cache-Control "public, max-age=31536000, must-revalidate"
# Security Headers & Content Security Policy (CSP)
header {
# Strict Content Security Policy (restricts scripts and connections to self, forbids frames)
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';"
# Prevent FLoC tracking
Permissions-Policy interest-cohort=()
# Security best practices
@@ -36,16 +53,10 @@ koalasync.shik3i.net {
X-Frame-Options DENY
Referrer-Policy no-referrer-when-downgrade
}
# Custom 404 page
handle_errors {
rewrite * /{err.status_code}.html
file_server
}
}
```
### Deployment Steps
1. Copy the contents of this folder to `/var/www/koalasync/website` on your server.
2. Update the path in your `Caddyfile`.
3. Reload Caddy: `caddy reload`.
## Local Development
1. Open `index.html` directly in any browser.
2. To test the invitation flow locally, use a local server (e.g., `npx serve .`) and navigate to `http://localhost:5000/join.html#join:test-room:test-pass`.
+221 -17
View File
@@ -71,17 +71,48 @@ document.addEventListener('DOMContentLoaded', () => {
if (actions) {
if (!isInstalled) {
actions.innerHTML = `
<a href="#" class="primary" style="text-align:center; text-decoration:none; display:block; padding: 1.2rem; background: var(--accent); color: white; border-radius: 12px; font-weight: 700;">GET IT ON CHROME WEBSTORE</a>
<a href="https://github.com/shik3i/KoalaSync" style="text-align:center; color:var(--accent); text-decoration:underline; font-size:0.85rem; margin-top:0.8rem; display:block; font-weight: 600;">Download via GitHub</a>
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">The extension is required to join and sync videos.</p>
`;
const isFirefox = navigator.userAgent.includes('Firefox');
if (isFirefox) {
actions.innerHTML = `
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/" class="primary btn-firefox" style="text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center; gap: 8px; padding: 1.2rem; border-radius: 12px; font-weight: 700;">
<span>🦊</span>
<span lang="en">GET IT ON MOZILLA ADD-ONS</span><span lang="de">IM FIREFOX ADD-ON STORE HERUNTERLADEN</span>
</a>
<a href="https://github.com/shik3i/KoalaSync" style="text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center; gap: 8px; padding: 0.8rem; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--glass-border); color: #e66000; border-radius: 12px; font-weight: 700; font-size: 0.85rem; margin-top: 0.5rem; transition: background 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<span lang="en">Download via GitHub</span><span lang="de">Über GitHub herunterladen</span>
</a>
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">
<span lang="en">The extension is required to join and sync videos.</span>
<span lang="de">Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.</span>
</p>
`;
} else {
actions.innerHTML = `
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc" class="primary" style="text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center; gap: 8px; padding: 1.2rem; background: var(--accent); color: white; border-radius: 12px; font-weight: 700;">
<img src="assets/chrome.svg" width="20" style="display: block;">
<span lang="en">GET IT ON CHROME WEBSTORE</span><span lang="de">IM CHROME WEB STORE HERUNTERLADEN</span>
</a>
<a href="https://github.com/shik3i/KoalaSync" style="text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center; gap: 8px; padding: 0.8rem; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--glass-border); color: var(--accent); border-radius: 12px; font-weight: 700; font-size: 0.85rem; margin-top: 0.5rem; transition: background 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<span lang="en">Download via GitHub</span><span lang="de">Über GitHub herunterladen</span>
</a>
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">
<span lang="en">The extension is required to join and sync videos.</span>
<span lang="de">Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.</span>
</p>
`;
}
} else {
actions.innerHTML = `
<div class="joining-spinner" style="text-align:center; padding: 1rem;">
<div style="font-size: 1.2rem; margin-bottom: 0.5rem;">🚀</div>
<div style="font-weight: 600; color: var(--accent);">Joining room automatically...</div>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">Your extension is taking care of it.</p>
<div style="font-weight: 600; color: var(--accent);">
<span lang="en">Joining room automatically...</span><span lang="de">Raum wird automatisch betreten...</span>
</div>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
<span lang="en">Your extension is taking care of it.</span><span lang="de">Deine Erweiterung kümmert sich darum.</span>
</p>
</div>
`;
@@ -161,14 +192,18 @@ document.addEventListener('DOMContentLoaded', () => {
if (success) {
if (icon) icon.textContent = '✅';
title.textContent = 'Erfolgreich!';
const isDE = document.documentElement.classList.contains('lang-de');
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
let count = 3;
let count = 2;
const updateCountdown = () => {
desc.innerHTML = `Du bist dem Raum beigetreten. <br><span style="color:var(--accent); font-weight:bold;">Dieser Tab schließt sich in ${count} Sekunden...</span>`;
const closingMsg = isDE
? `Du bist dem Raum beigetreten. <br><span style="color:var(--accent); font-weight:bold;">Dieser Tab schließt sich in ${count} Sekunden...</span>`
: `You joined the room. <br><span style="color:var(--accent); font-weight:bold;">This tab will close in ${count} seconds...</span>`;
desc.innerHTML = closingMsg;
if (count <= 0) {
window.close();
desc.textContent = 'Beitritt erfolgreich! Du kannst diesen Tab jetzt manuell schließen.';
desc.textContent = isDE ? 'Beitritt erfolgreich! Du kannst diesen Tab jetzt manuell schließen.' : 'Joined successfully! You can close this tab manually.';
} else {
count--;
setTimeout(updateCountdown, 1000);
@@ -176,20 +211,23 @@ document.addEventListener('DOMContentLoaded', () => {
};
updateCountdown();
actions.innerHTML = '<button class="primary" onclick="window.close()" style="background:var(--success); width: 100%;">TAB JETZT SCHLIESSEN</button>';
const closeLabel = isDE ? 'TAB JETZT SCHLIESSEN' : 'CLOSE TAB NOW';
actions.innerHTML = `<button class="primary" onclick="window.close()" style="background:var(--success); width: 100%;">${closeLabel}</button>`;
} else {
if (icon) icon.textContent = '❌';
title.textContent = 'Fehler';
desc.textContent = `Beitritt fehlgeschlagen: ${message}`;
actions.innerHTML = '<button class="primary" onclick="location.reload()" style="width: 100%;">ERNEUT VERSUCHEN</button>';
const isDE = document.documentElement.classList.contains('lang-de');
title.textContent = isDE ? 'Fehler' : 'Error';
desc.textContent = isDE ? `Beitritt fehlgeschlagen: ${message}` : `Join failed: ${message}`;
const retryLabel = isDE ? 'ERNEUT VERSUCHEN' : 'TRY AGAIN';
actions.innerHTML = `<button class="primary" onclick="location.reload()" style="width: 100%;">${retryLabel}</button>`;
}
} else {
const banner = document.getElementById('koala-banner');
if (banner) {
if (success) {
banner.style.background = 'var(--success)';
banner.innerHTML = '<div class="container">✅ Joined! This tab will close in 3s...</div>';
setTimeout(() => window.close(), 3000);
banner.innerHTML = '<div class="container">✅ Joined! This tab will close in 2s...</div>';
setTimeout(() => window.close(), 2000);
} else {
banner.style.background = 'var(--error)';
banner.innerHTML = '';
@@ -202,5 +240,171 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
const updateDynamicVersion = async () => {
try {
const response = await fetch('version.json');
if (!response.ok) return;
const data = await response.json();
const { version, date } = data;
if (!version || !date) return;
const releaseDate = new Date(date);
const now = new Date();
const diffMs = now - releaseDate;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMins = Math.floor(diffMs / (1000 * 60));
let relativeTimeEn = '';
let relativeTimeDe = '';
if (diffDays > 0) {
relativeTimeEn = `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
relativeTimeDe = `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
} else if (diffHours > 0) {
relativeTimeEn = `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`;
relativeTimeDe = `vor ${diffHours} ${diffHours === 1 ? 'Stunde' : 'Stunden'}`;
} else if (diffMins > 0) {
relativeTimeEn = `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`;
relativeTimeDe = `vor ${diffMins} ${diffMins === 1 ? 'Minute' : 'Minuten'}`;
} else {
relativeTimeEn = 'just now';
relativeTimeDe = 'gerade eben';
}
const badgeEn = document.querySelector('.version-text-en');
const badgeDe = document.querySelector('.version-text-de');
if (badgeEn) {
badgeEn.textContent = `v${version} OUT NOW • ${relativeTimeEn}`;
}
if (badgeDe) {
badgeDe.textContent = `v${version} JETZT VERFÜGBAR • ${relativeTimeDe}`;
}
} catch (e) {
console.warn('Failed to fetch dynamic version info:', e);
}
};
// Extension Mockup Tab Switcher
const mockTabs = document.querySelectorAll('.mock-tab');
const mockScreens = document.querySelectorAll('.mock-screen');
mockTabs.forEach(tab => {
tab.addEventListener('click', () => {
mockTabs.forEach(t => t.classList.remove('active'));
mockScreens.forEach(s => s.classList.remove('active'));
tab.classList.add('active');
const targetId = tab.getAttribute('data-target');
const targetScreen = document.getElementById(targetId);
if (targetScreen) {
targetScreen.classList.add('active');
}
});
});
// Terminal Tab Switcher
const termTabBtns = document.querySelectorAll('.terminal-tab-btn');
const termPanes = document.querySelectorAll('.terminal-pane');
termTabBtns.forEach(btn => {
btn.addEventListener('click', () => {
termTabBtns.forEach(b => b.classList.remove('active'));
termPanes.forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const targetPaneId = btn.getAttribute('data-tab');
const targetPane = document.getElementById(targetPaneId);
if (targetPane) {
targetPane.classList.add('active');
}
});
});
// Terminal Clipboard Copy
const copyBtn = document.querySelector('.terminal-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const activePane = document.querySelector('.terminal-pane.active');
if (!activePane) return;
const codeElement = activePane.querySelector('code');
if (!codeElement) return;
const textToCopy = codeElement.innerText || codeElement.textContent;
navigator.clipboard.writeText(textToCopy).then(() => {
const isDE = document.documentElement.classList.contains('lang-de');
const originalHTML = copyBtn.innerHTML;
copyBtn.innerHTML = isDE ? '✅ Kopiert!' : '✅ Copied!';
copyBtn.disabled = true;
setTimeout(() => {
copyBtn.innerHTML = originalHTML;
copyBtn.disabled = false;
}, 2000);
}).catch(err => {
console.error('Failed to copy text: ', err);
});
});
}
// Mobile Hamburger Menu Toggle
const hamburger = document.querySelector('.hamburger');
const navLinks = document.querySelector('.nav-links');
if (hamburger && navLinks) {
hamburger.addEventListener('click', () => {
navLinks.classList.toggle('open');
});
}
// Language Selection Umschalter
const toggleLanguage = (e) => {
if (e) e.preventDefault();
const html = document.documentElement;
const currentIsEnglish = html.classList.contains('lang-en');
const newLang = currentIsEnglish ? 'de' : 'en';
html.classList.remove('lang-en', 'lang-de');
html.classList.add('lang-' + newLang);
html.lang = newLang;
localStorage.setItem('koala_lang', newLang);
// Update titles dynamically based on page
var path = window.location.pathname;
var isIndex = path === '/' || path.endsWith('index.html') || path.split('/').pop() === '';
var isJoin = path.endsWith('join.html');
if (isIndex) {
const titles = {
en: 'KoalaSync | Real-time Video Synchronization for Friends',
de: 'KoalaSync | Echtzeit-Video-Synchronisation für Freunde'
};
document.title = titles[newLang] || titles.en;
} else if (isJoin) {
const titles = {
en: 'Join Room | KoalaSync',
de: 'Raum beitreten | KoalaSync'
};
document.title = titles[newLang] || titles.en;
}
};
document.querySelectorAll('.lang-toggle').forEach(btn => {
btn.addEventListener('click', toggleLanguage);
});
// Impressum Email Obfuscation Click Reveal
document.querySelectorAll('.email-reveal').forEach(el => {
el.addEventListener('click', function() {
const user = this.getAttribute('data-user');
const domain = this.getAttribute('data-domain');
if (user && domain) {
this.innerHTML = `${user}@${domain}`;
}
});
});
checkInvite();
updateDynamicVersion();
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 26 KiB

+79 -20
View File
@@ -1,11 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutz | KoalaSync</title>
<title>Datenschutz / Privacy Policy | KoalaSync</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
<meta name="robots" content="noindex">
<script src="lang-init.js"></script>
</head>
<body>
<div class="bg-blobs">
@@ -20,54 +22,110 @@
<img src="assets/logo.png" alt="KoalaSync Logo">
<span>KoalaSync</span>
</a>
<button class="hamburger" aria-label="Menu"></button>
<div class="nav-links">
<a href="index.html">Home</a>
<a href="https://github.com/shik3i/KoalaSync" target="_blank">GitHub</a>
<a href="index.html"><span lang="de">Startseite</span><span lang="en">Home</span></a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank">GitHub</a>
<a class="lang-toggle" style="cursor: pointer;">🌐 EN/DE</a>
</div>
</div>
</nav>
<main class="legal-content">
<div class="legal-card" data-reveal style="padding: 2rem;">
<h1>Datenschutz</h1>
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
<h1 lang="de">Datenschutz</h1>
<h1 lang="en">Privacy Policy</h1>
<p lang="de" style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
Sicherheit & Privatsphäre
</p>
<p lang="en" style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
Security & Privacy
</p>
<section>
<h2>1. Hosting & Logfiles</h2>
<p>
<h2>
<span lang="de">1. Hosting & Logfiles</span>
<span lang="en">1. Hosting & Logfiles</span>
</h2>
<p lang="de">
Diese Seite wird auf einem privaten Server gehostet. Zur Gewährleistung der Stabilität werden standardmäßige Server-Logs (IP, Browser, Zeit) erhoben, aber nicht mit Personen verknüpft und nach 7 Tagen automatisch gelöscht.
</p>
<p lang="en">
This site is hosted on a private server. To ensure stability, standard server logs (IP, browser, time) are collected, but not linked to individuals and are automatically deleted after 7 days.
</p>
</section>
<section>
<h2>2. Keine Drittanbieter & Tracking</h2>
<p>
<h2>
<span lang="de">2. Keine Drittanbieter & Open Source</span>
<span lang="en">2. No Third Parties & Open Source</span>
</h2>
<p lang="de">
KoalaSync verzichtet bewusst auf Analyse-Tools, Tracking-Cookies oder Werbenetzwerke. Wir laden keine Ressourcen von Drittanbietern (wie Google Fonts) nach, um Ihre Privatsphäre maximal zu schützen.
</p>
<p lang="de" style="margin-top: 0.5rem;">
Da KoalaSync vollständig Open Source ist, kann zudem jede Zeile Code auf unserem <a href="https://github.com/shik3i/KoalaSync" target="_blank" style="color: var(--accent);">GitHub-Repository</a> öffentlich eingesehen und auf Sicherheit geprüft werden.
</p>
<p lang="en">
KoalaSync deliberately avoids analytics tools, tracking cookies, or advertising networks. We do not load any third-party resources (such as Google Fonts) to maximize the protection of your privacy.
</p>
<p lang="en" style="margin-top: 0.5rem;">
Since KoalaSync is 100% open-source, every single line of code can also be publicly viewed and audited for security on our <a href="https://github.com/shik3i/KoalaSync" target="_blank" style="color: var(--accent);">GitHub repository</a>.
</p>
</section>
<section>
<h2>3. Relay-Server Architektur</h2>
<p>
<h2>
<span lang="de">3. Relay-Server Architektur</span>
<span lang="en">3. Relay Server Architecture</span>
</h2>
<p lang="de">
Unser Relay-Server arbeitet ausschließlich im Arbeitsspeicher (RAM). Nachrichten zwischen Teilnehmern werden nicht auf Festplatten gespeichert und sind flüchtig. Sobald ein Raum geschlossen wird, werden alle zugehörigen Metadaten sofort gelöscht.
</p>
<p lang="en">
Our relay server operates exclusively in memory (RAM). Messages between participants are not stored on hard drives and are volatile. As soon as a room is closed, all associated metadata is immediately deleted.
</p>
</section>
<section>
<h2>4. Brute-Force Schutz</h2>
<p>
<h2>
<span lang="de">4. Browser-Erweiterung (Extension)</span>
<span lang="en">4. Browser Extension</span>
</h2>
<p lang="de">
Um die geräteübergreifende Synchronisation zu ermöglichen, erfasst die KoalaSync Browser-Erweiterung temporär Daten des aktuell aktiven Video-Tabs (z. B. Tab-Titel, Medien-Metadaten wie den Videotitel sowie den Wiedergabestatus). Diese Daten werden ausschließlich zur Synchronisation an die anderen Teilnehmer in Ihrem Raum gesendet. Es wird ausdrücklich <strong>kein allgemeiner Browserverlauf (Browsing History)</strong> ausgelesen, gespeichert oder übermittelt.
</p>
<p lang="en">
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>
</section>
<section>
<h2>
<span lang="de">5. Brute-Force Schutz</span>
<span lang="en">5. Brute-Force Protection</span>
</h2>
<p lang="de">
Zur Sicherheit unserer Nutzer speichern wir fehlgeschlagene Login-Versuche (IP-Adresse und Raum-ID) für maximal 15 Minuten im RAM, um automatisierte Angriffe zu verhindern. Diese Daten werden danach rückstandslos gelöscht.
</p>
<p lang="en">
For the security of our users, we store failed login attempts (IP address and room ID) for a maximum of 15 minutes in RAM to prevent automated attacks. This data is deleted without a trace afterwards.
</p>
</section>
<section>
<h2>5. Ihre Rechte</h2>
<p>
<h2>
<span lang="de">6. Ihre Rechte</span>
<span lang="en">6. Your Rights</span>
</h2>
<p lang="de">
Sie haben das Recht auf Auskunft, Berichtigung oder Löschung Ihrer Daten. Da wir jedoch keine personenbezogenen Daten dauerhaft speichern, ist eine Zuordnung zu Ihrer Person in der Regel technisch nicht möglich.
</p>
<p>Kontakt bei Fragen: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_datenschutz' + '@' + 'koalamail.rocks'">[E-Mail anzeigen]</span></p>
<p lang="en">
You have the right to information, correction, or deletion of your data. However, since we do not store any personal data permanently, linking data to your person is technically impossible in most cases.
</p>
<p lang="de">Kontakt bei Fragen: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_datenschutz' + '@' + 'koalamail.rocks'">[E-Mail anzeigen]</span></p>
<p lang="en">Contact for questions: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_datenschutz' + '@' + 'koalamail.rocks'">[Show Email]</span></p>
</section>
</div>
</main>
@@ -75,10 +133,11 @@
<footer>
<div class="container">
<p>&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
<p lang="de" style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
<p lang="en" style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1.5rem;">
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;"><span lang="de">Impressum</span><span lang="en">Legal Notice</span></a>
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;"><span lang="de">Datenschutz</span><span lang="en">Privacy Policy</span></a>
</div>
</div>
</footer>
+66 -25
View File
@@ -1,11 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum | KoalaSync</title>
<title>Impressum / Legal Notice | KoalaSync</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
<meta name="robots" content="noindex">
<script src="lang-init.js"></script>
</head>
<body>
<div class="bg-blobs">
@@ -20,57 +22,95 @@
<img src="assets/logo.png" alt="KoalaSync Logo">
<span>KoalaSync</span>
</a>
<button class="hamburger" aria-label="Menu"></button>
<div class="nav-links">
<a href="index.html">Home</a>
<a href="https://github.com/shik3i/KoalaSync" target="_blank">GitHub</a>
<a href="index.html"><span lang="de">Startseite</span><span lang="en">Home</span></a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank">GitHub</a>
<a class="lang-toggle" style="cursor: pointer;">🌐 EN/DE</a>
</div>
</div>
</nav>
<main class="legal-content">
<div class="legal-card" data-reveal style="padding: 2rem;">
<h1>Impressum</h1>
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
<h1 lang="de">Impressum</h1>
<h1 lang="en">Legal Notice</h1>
<p lang="de" style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
Transparenz & Identifikation
</p>
<p lang="en" style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
Transparency & Identification
</p>
<section>
<h2>Betreiber & Kontakt</h2>
<p>Administrator KoalaSync (Privatperson)</p>
<p>E-Mail: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_admin' + '@' + 'koalamail.rocks'">[E-Mail anzeigen]</span></p>
<h2>
<span lang="de">Betreiber & Kontakt</span>
<span lang="en">Operator & Contact</span>
</h2>
<p lang="de">Administrator KoalaSync (Privatperson)</p>
<p lang="en">Administrator KoalaSync (Private Individual)</p>
<p lang="de">E-Mail: <span class="email-reveal" data-user="koalasync_admin" data-domain="koalamail.rocks" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span></p>
<p lang="en">E-Mail: <span class="email-reveal" data-user="koalasync_admin" data-domain="koalamail.rocks" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[Show Email]</span></p>
</section>
<section style="opacity: 0.8;">
<h2>Privatprojekt-Hinweis</h2>
<p>
<h2>
<span lang="de">Privatprojekt-Hinweis</span>
<span lang="en">Private Project Notice</span>
</h2>
<p lang="de">
Diese Website ist ein rein privates Hobby-Projekt und dient keinen geschäftsmäßigen Zwecken.
Eine Impressumspflicht nach § 5 DDG (ehemals TMG) besteht daher nicht.
Diese Angaben erfolgen rein freiwillig zur Transparenz gegenüber der Community.
</p>
<p lang="en">
This website is a purely private hobby project and does not serve any commercial purposes.
Therefore, there is no obligation to provide a legal notice according to § 5 DDG (formerly TMG).
This information is provided voluntarily for transparency towards the community.
</p>
</section>
<section>
<h2>Haftung für Inhalte</h2>
<p>
<h2>
<span lang="de">Haftung für Inhalte</span>
<span lang="en">Liability for Content</span>
</h2>
<p lang="de">
Gemäß § 7 Abs.1 DDG sind wir für eigene Inhalte verantwortlich. Nach §§ 8 bis 10 DDG sind wir jedoch nicht verpflichtet,
übermittelte oder gespeicherte fremde Informationen zu überwachen.
</p>
</section>
<section>
<h2>Haftung für Links</h2>
<p>
Unser Angebot enthält Links zu externen Websites Dritter. Auf deren Inhalte haben wir keinen Einfluss und
können daher keine Gewähr für diese fremden Inhalte übernehmen.
<p lang="en">
According to § 7 Abs.1 DDG we are responsible for our own content. According to §§ 8 to 10 DDG, however, we are not obligated to monitor transmitted or stored third-party information.
</p>
</section>
<section>
<h2>Urheberrecht</h2>
<p>
<h2>
<span lang="de">Haftung für Links</span>
<span lang="en">Liability for Links</span>
</h2>
<p lang="de">
Unser Angebot enthält Links zu externen Websites Dritter. Auf deren Inhalte haben wir keinen Einfluss und
können daher keine Gewähr für diese fremden Inhalte übernehmen.
</p>
<p lang="en">
Our offer contains links to external third-party websites. We have no influence on their content and therefore cannot assume any liability for these external contents.
</p>
</section>
<section>
<h2>
<span lang="de">Urheberrecht</span>
<span lang="en">Copyright</span>
</h2>
<p lang="de">
Die durch die Seitenbetreiber erstellten Inhalte auf diesen Seiten unterliegen dem deutschen Urheberrecht.
Vervielfältigung, Bearbeitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung.
</p>
<p lang="en">
The content and works created by the site operators on these pages are subject to German copyright law.
Duplication, processing, and any kind of exploitation outside the limits of copyright require written consent.
</p>
</section>
</div>
</main>
@@ -78,10 +118,11 @@
<footer>
<div class="container">
<p>&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
<p lang="de" style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
<p lang="en" style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1.5rem;">
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;"><span lang="de">Impressum</span><span lang="en">Legal Notice</span></a>
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;"><span lang="de">Datenschutz</span><span lang="en">Privacy Policy</span></a>
</div>
</div>
</footer>
+411 -43
View File
@@ -3,15 +3,20 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KoalaSync | Real-time Video Synchronization for Friends</title>
<meta name="description" content="Watch YouTube, Twitch, and HTML5 videos perfectly synchronized with friends. KoalaSync is a privacy-first, open-source Chrome extension.">
<title>KoalaSync | Sync Netflix, Emby, Jellyfin & Any Video with Friends Browser Extension</title>
<meta name="description" content="Watch Netflix, Emby, Jellyfin, YouTube, Twitch and any HTML5 video in perfect sync with friends. KoalaSync is a privacy-first, open-source browser extension for Chrome and Firefox. Works on almost any site with a video element.">
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
<meta property="og:title" content="KoalaSync | Sync your videos">
<meta property="og:description" content="Watch together, stay in sync. Privacy-first video synchronization.">
<meta property="og:image" content="assets/hero.png">
<link rel="canonical" href="https://sync.koalastuff.net/">
<meta name="robots" content="index, follow">
<meta property="og:title" content="KoalaSync | Sync Netflix, Emby, Jellyfin & Any Video with Friends">
<meta property="og:description" content="Watch Netflix, Emby, Jellyfin, YouTube, Twitch and any HTML5 video in perfect sync. Privacy-first, open-source browser extension for Chrome & Firefox.">
<meta property="og:image" content="https://sync.koalastuff.net/assets/logo.png">
<meta property="og:type" content="website">
<meta property="og:url" content="https://sync.koalastuff.net/">
<script src="lang-init.js"></script>
</head>
<body>
@@ -27,10 +32,18 @@
<img src="assets/logo.png" alt="KoalaSync Logo">
<span>KoalaSync</span>
</div>
<button class="hamburger" aria-label="Menu"></button>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#how-it-works">How it works</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank">GitHub</a>
<a href="#features"><span lang="en">Features</span><span lang="de">Funktionen</span></a>
<a href="#how-it-works"><span lang="en">How it works</span><span lang="de">So funktioniert's</span></a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
<a class="lang-toggle" style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
<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" style="width: 16px; height: 16px; display: block;"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
EN/DE
</a>
</div>
</div>
</nav>
@@ -38,17 +51,195 @@
<header class="hero">
<div class="container hero-grid">
<div class="hero-text">
<div class="version-badge" data-reveal>v1.2.0 OUT NOW</div>
<h1 data-reveal>Watch Together.<br>Sync Perfectly.</h1>
<p data-reveal>KoalaSync brings friends closer through synchronized video playback. No lag, no tracking, just shared moments.</p>
<div class="version-badge" data-reveal><span lang="en" class="version-text-en">v1.5.3 OUT NOW</span><span lang="de" class="version-text-de">v1.5.3 JETZT VERFÜGBAR</span></div>
<h1 data-reveal>
<span lang="en">Watch Together.<br>Sync Perfectly.</span>
<span lang="de">Gemeinsam schauen.<br>Perfekt synchron.</span>
</h1>
<h2 class="hero-subtitle" data-reveal>
<span lang="en">Your remote movie night without lags. No registration, no data collection. Just share a link and watch together.</span>
<span lang="de">Dein Kino-Abend auf Distanz. Keine Lags, keine Anmeldung. Einfach Link teilen und zusammen schauen.</span>
</h2>
<div class="cta-group" data-reveal>
<a href="#" class="btn btn-primary">
<img src="assets/chrome.svg" width="20" style="filter: brightness(100)">
Add to Chrome
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc" class="btn btn-primary">
<img src="assets/chrome.svg" width="20">
<span lang="en">Add to Chrome</span><span lang="de">Zu Chrome hinzufügen</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" class="btn btn-secondary">
GitHub Repo
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/" class="btn btn-firefox">
🦊
<span lang="en">Add to Firefox</span><span lang="de">Zu Firefox hinzufügen</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync/releases" target="_blank" class="btn btn-secondary" style="display: inline-flex; align-items: center; gap: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub Releases
</a>
</div>
</div>
<!-- Interactive CSS Extension Mockup -->
<div class="hero-mockup-wrapper" data-reveal>
<div class="extension-mockup">
<div class="mock-header">
<img src="assets/logo.png" alt="KoalaSync">
<span>KOALASYNC</span>
</div>
<div class="mock-tabs">
<button class="mock-tab active" data-target="mock-room">Room</button>
<button class="mock-tab" data-target="mock-sync">Sync</button>
<button class="mock-tab" data-target="mock-settings">Settings</button>
<button class="mock-tab" data-target="mock-dev">Dev</button>
</div>
<div class="mock-body">
<!-- ROOM TAB -->
<div id="mock-room" class="mock-screen active">
<div class="mock-joined-room" style="display: flex; justify-content: space-between; align-items: center; border-left: 4px solid var(--accent); padding: 8px 10px; background: var(--card); border-radius: 8px; margin-bottom: 12px;">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.6rem; text-transform: uppercase; color: var(--text-muted); font-weight: 700; line-height: 1;"><span lang="en">Active Room</span><span lang="de">Aktiver Raum</span></span>
<span class="mock-room-name" style="font-weight: 700; color: var(--accent); font-size: 0.9rem; letter-spacing: 0.5px; margin-top: 2px;">brave-eagle-80</span>
</div>
<span class="mock-server-badge" style="font-size: 0.6rem; color: var(--text-muted);"><span lang="en">Official Server</span><span lang="de">Offizieller Server</span></span>
</div>
<div class="mock-card">
<div class="mock-label"><span lang="en">Invite Link</span><span lang="de">Einladungs-Link</span></div>
<div class="mock-invite-box">
<input type="text" class="mock-input" value="https://sync.koalastuff.net/join.html#join:brave-eagle-80:pass" readonly>
<button class="mock-btn" style="padding: 0.35rem 0.5rem;"><span lang="en">Copy</span><span lang="de">Kopieren</span></button>
</div>
</div>
<div class="mock-card" style="margin-bottom: 12px;">
<div class="mock-label"><span lang="en">Peers in Room</span><span lang="de">Teilnehmer im Raum</span></div>
<div class="mock-peer-list">
<div class="mock-peer-item">
<div class="mock-peer-header">
<span class="mock-peer-name">CoolUsername</span>
<span class="mock-peer-meta"><span lang="en">YOU</span><span lang="de">ICH</span></span>
</div>
<div class="mock-peer-tab">🎬 Germany's Next Flopmodel - S21E12</div>
<div class="mock-peer-status"><span style="color:#22c55e;"></span> 0:55 • <span lang="en">Playing</span><span lang="de">Wiedergabe</span></div>
</div>
<div class="mock-peer-item">
<div class="mock-peer-header">
<span class="mock-peer-name">KoalaPC</span>
<span class="mock-peer-meta"><span lang="en">Peer</span><span lang="de">Partner</span></span>
</div>
<div class="mock-peer-tab">🎬 Germany's Next Flopmodel - S21E12</div>
<div class="mock-peer-status"><span style="color:#22c55e;"></span> 0:55 • <span lang="en">Playing</span><span lang="de">Wiedergabe</span></div>
</div>
</div>
</div>
<button class="mock-btn" style="background:#ef4444; width:100%; border: none; padding: 10px; border-radius: 8px; color: white; font-weight: 700; cursor: pointer; border: 1px solid #ef4444;"><span lang="en">Leave Room</span><span lang="de">Raum verlassen</span></button>
</div>
<!-- SYNC TAB -->
<div id="mock-sync" class="mock-screen">
<div class="mock-form-group" style="margin-bottom: 12px;">
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Select Video</span><span lang="de">Video auswählen</span>
</label>
<select class="mock-input" style="width: 100%; box-sizing: border-box; background: var(--card); border: 1px solid #334155; color: white; padding: 8px; border-radius: 8px; font-family: inherit; font-size: 0.75rem; outline: none; cursor: default;">
<option>🎬 Germany's Next Flopmodel - S21E12</option>
</select>
</div>
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Remote Control</span><span lang="de">Fernsteuerung</span>
</label>
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<button class="mock-btn mock-btn-play" style="flex: 1; background: #22c55e; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;"><span lang="en">▶ Play</span><span lang="de">▶ Start</span></button>
<button class="mock-btn mock-btn-pause" style="flex: 1; background: #ef4444; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;"><span lang="en">⏸ Pause</span><span lang="de">⏸ Pause</span></button>
</div>
<button class="mock-btn mock-btn-force" style="background: linear-gradient(135deg, #6366f1, #a855f7); width: 100%; padding: 10px; color: white; border: none; font-weight: 700; border-radius: 8px; margin-bottom: 15px; cursor: pointer;">
<span lang="en">⚡ Sync All Viewers</span><span lang="de">⚡ Alle synchronisieren</span>
</button>
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Last Activity Status</span><span lang="de">Letzte Aktivität</span>
</label>
<div class="mock-card" style="margin-bottom: 15px; min-height: 70px; background: var(--card); border: 1px solid #334155; padding: 10px; border-radius: 8px; display: flex; flex-direction: column; justify-content: center; gap: 4px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 700; color: #ef4444; font-size: 0.8rem;">PAUSE</span>
<span style="font-size: 0.65rem; color: #22c55e; font-weight: 700;">● In Sync</span>
</div>
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 2px;">
<span lang="en">Pause triggered by CoolUsername</span><span lang="de">Pause ausgelöst durch CoolUsername</span>
</div>
</div>
</div>
<!-- SETTINGS TAB -->
<div id="mock-settings" class="mock-screen">
<div class="mock-form-group" style="margin-bottom: 12px;">
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Your Username</span><span lang="de">Dein Benutzername</span>
</label>
<input type="text" class="mock-input" value="CoolUsername" readonly style="width:100%; box-sizing:border-box; background: var(--card); border: 1px solid #334155; color: white; padding: 8px; border-radius: 8px;">
</div>
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #334155;">
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: pointer;" for="chk-noise">
<span lang="en">Show Video Tabs Only</span><span lang="de">Nur Video-Tabs anzeigen</span>
</label>
<input type="checkbox" id="chk-noise" checked disabled style="width: auto; cursor: not-allowed;">
</div>
<div 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: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: pointer;" for="chk-binge">
<span lang="en">Auto-Sync Next Episode</span><span lang="de">Nächste Folge auto-syncen</span>
</label>
<input type="checkbox" id="chk-binge" checked disabled style="width: auto; cursor: not-allowed;">
</div>
<div style="font-size: 0.65rem; color: var(--text-muted); padding: 4px; line-height: 1.3;">
<p style="margin: 2px 0;"><span lang="en">Username helps others identify you.</span><span lang="de">Benutzername hilft anderen, dich zu erkennen.</span></p>
<p style="margin: 2px 0;"><span lang="en">Noise filtering hides common non-video sites.</span><span lang="de">Noise-Filterung blendet allgemeine Seiten aus.</span></p>
</div>
<div style="margin-top: auto; padding-top: 8px; border-top: 1px solid #334155;">
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Troubleshooting</span><span lang="de">Fehlerbehebung</span>
</label>
<button class="mock-btn" style="background:#334155; width:100%; border: none; padding: 8px; border-radius: 8px; color: white; font-weight: 600; cursor: pointer;">
<span lang="en">Regenerate Peer ID</span><span lang="de">Peer ID regenerieren</span>
</button>
</div>
</div>
<!-- DEV TAB -->
<div id="mock-dev" class="mock-screen">
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Connection Status</span><span lang="de">Verbindungsstatus</span>
</label>
<div style="display:flex; align-items:center; gap: 10px; background: var(--card); border: 1px solid #334155; padding: 10px; border-radius: 8px; margin-bottom: 12px;">
<span class="status-dot status-online" style="width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: #22c55e; box-shadow: 0 0 8px #22c55e;"></span>
<span style="flex:1; font-size: 0.75rem; font-weight: 600; color: white;">
<span lang="en">Connected</span><span lang="de">Verbunden</span>
</span>
<button class="mock-btn" style="width: auto; padding: 4px 8px; font-size: 0.65rem; border: none; background: #334155; color: white; border-radius: 6px; cursor: pointer;">
<span lang="en">Copy Logs</span><span lang="de">Logs kopieren</span>
</button>
</div>
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Video Debug Info</span><span lang="de">Video-Debug-Info</span>
</label>
<div style="background: var(--card); border: 1px solid #334155; padding: 10px; border-radius: 8px; font-size: 0.65rem; font-family: monospace; color: var(--text-muted); line-height: 1.4; margin-bottom: 12px;">
<div>readyState: HAVE_ENOUGH_DATA</div>
<div>seekDelta: 0.05s</div>
<div>buffered: 300.2s</div>
<div>engineState: <span style="color:#22c55e; font-weight:700;">SYNCED</span></div>
</div>
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
<span lang="en">Logs (Last 50)</span><span lang="de">Protokoll (Letzte 50)</span>
</label>
<div style="max-height: 75px; overflow-y: auto; font-size: 0.6rem; font-family: monospace; background: #000; padding: 8px; border-radius: 8px; color: #ccc; line-height: 1.4;">
<div style="color:#22c55e;">[18:37:32] EVENT: PLAY -&gt; ACK</div>
<div>[18:37:32] FORCE_SYNC -&gt; 0.05s diff</div>
<div>[18:37:38] EVENT: PAUSE -&gt; BROADCAST</div>
<div style="color:var(--text-muted);">[18:37:42] content_heartbeat: active</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -56,29 +247,68 @@
<section id="features">
<div class="container">
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 1rem;">Why KoalaSync?</h2>
<p style="text-align: center; color: var(--text-muted); margin-bottom: 4rem;">Built for performance, privacy, and simplicity.</p>
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 1rem;">
<span lang="en">Why KoalaSync?</span><span lang="de">Warum KoalaSync?</span>
</h2>
<p style="text-align: center; color: var(--text-muted); margin-bottom: 4rem;">
<span lang="en">Built for reliable sync, privacy-first design, and easy setup.</span>
<span lang="de">Entwickelt für zuverlässigen Sync, Datenschutz und einfache Einrichtung.</span>
</p>
<div class="features-grid">
<div class="feature-card" data-reveal>
<div class="feature-icon"></div>
<h3>Real-time Sync</h3>
<p>Proprietary two-phase synchronization protocol ensures sub-millisecond precision across all peers.</p>
<h3>
<span lang="en">Full Control / Instant Sync</span>
<span lang="de">Volle Kontrolle & Echtzeit</span>
</h3>
<p lang="en">One pauses, everyone pauses. One seeks, everyone follows. Our two-phase sync protocol coordinates playback in real time across all participants.</p>
<p lang="de">Einer pausiert, alle pausieren. Einer spult, alle folgen. Unser Zwei-Phasen-Synchronisationsprotokoll koordiniert die Wiedergabe aller Teilnehmer in Echtzeit.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🎬</div>
<h3>Episode Auto-Sync</h3>
<p>New in v1.2.0: Perfectly sync series binges. All peers wait until everyone has loaded the next episode.</p>
<h3>
<span lang="en">Endless Binge-Watching</span>
<span lang="de">Grenzenloses Bingen</span>
</h3>
<p lang="en">Autoplay in sync. KoalaSync automatically detects episode transitions and holds playback until all peers have successfully loaded the next video.</p>
<p lang="de">Nächste Episode startet für jeden zeitgleich. KoalaSync erkennt den Episodenwechsel und pausiert, bis jeder Teilnehmer das neue Video fertig geladen hat.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🛡️</div>
<h3>Privacy First</h3>
<p>Zero data persistence. Our relay server runs entirely in RAM and collects no telemetry or logs.</p>
<h3>
<span lang="en">Zero Accounts / Pure Privacy</span>
<span lang="de">Keine Accounts / Datenschutz</span>
</h3>
<p lang="en">No registration, no tracking, no bullshit. Just share a link and watch. The server runs entirely in RAM and purges your room when you leave.</p>
<p lang="de">Kein Login. Keine Daten. Einfach Link teilen und schauen. Der Server läuft flüchtig im RAM und löscht deinen Raum komplett nach dem Verlassen.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon"></div>
<h3>Smart Match</h3>
<p>Find the right tab instantly. KoalaSync highlights and sorts matching video tabs for you.</p>
<div class="feature-icon">🌐</div>
<h3>
<span lang="en">Universal HTML5 Support</span>
<span lang="de">Universeller HTML5-Support</span>
</h3>
<p lang="en">Works on YouTube, Twitch, Netflix, Jellyfin, Emby, and any standard webpage containing a HTML5 video element. Also compatible with custom self-hosted setups.</p>
<p lang="de">Unterstützt YouTube, Twitch, Netflix, Jellyfin, Emby und jede beliebige Webseite mit einem HTML5-Video-Tag. Ideal auch für eigene Medienbibliotheken.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🐳</div>
<h3>
<span lang="en">Self-Hostable & Docker-Ready</span>
<span lang="de">Self-Hostable & Docker-Ready</span>
</h3>
<p lang="en">Our official servers are free and fast, but you <strong>can</strong> take full ownership if you want to. Launch your own private relay server in seconds via Docker.</p>
<p lang="de">Unsere Server sind kostenlos und schnell, aber du <strong>kannst</strong> auch die volle Kontrolle übernehmen, wenn du willst. Starte dein eigenes privates Relay in Sekunden via Docker.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🔗</div>
<h3>
<span lang="en">Instant Invites / 1-Click Join</span>
<span lang="de">Direkte Einladungen & 1-Klick Beitritt</span>
</h3>
<p lang="en">No IP addresses or passwords to exchange. Share a generated invite link with your friends to let them join your room automatically with a single click.</p>
<p lang="de">Keine lästigen IPs oder Passwörter austauschen. Teile einfach einen Einladungslink mit deinen Freunden, um sie mit einem Klick in den Raum zu holen.</p>
</div>
</div>
@@ -87,32 +317,165 @@
<section id="how-it-works" style="background: rgba(255,255,255,0.02)">
<div class="container">
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 4rem;">Getting Started</h2>
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 4rem;">
<span lang="en">Getting Started</span><span lang="de">Loslegen</span>
</h2>
<div class="steps">
<div class="step">
<div class="step-text" data-reveal>
<div class="step-num">01</div>
<h3>Install Extension</h3>
<p>Add KoalaSync to your browser from the Chrome Web Store or download the latest developer ZIP from GitHub.</p>
<h3><span lang="en">Install Extension</span><span lang="de">Erweiterung installieren</span></h3>
<p lang="en">Add KoalaSync to your browser from the Chrome Web Store, Firefox Add-ons, or download the latest developer ZIP from GitHub.</p>
<p lang="de">Füge KoalaSync aus dem Chrome Web Store, den Firefox Add-ons oder über die Entwickler-ZIP von GitHub zu deinem Browser hinzu.</p>
</div>
<!-- Custom Step 1 Illustration -->
<div class="step-illustration-1" data-reveal>
<div class="illus-browser-bar">
<div class="illus-browser-header">
<div class="illus-dots">
<span class="illus-dot"></span>
<span class="illus-dot"></span>
<span class="illus-dot"></span>
</div>
<div class="illus-address-bar"></div>
<div class="illus-extension-icon"><img src="assets/logo.png" alt="KoalaSync" style="width: 12px; height: 12px; display: block; object-fit: contain;"></div>
</div>
<div class="illus-browser-body">
<div class="illus-line-placeholder" style="width: 80%;"></div>
<div class="illus-line-placeholder" style="width: 50%;"></div>
<div class="illus-line-placeholder" style="width: 90%;"></div>
</div>
</div>
</div>
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></div>
</div>
<div class="step">
<div class="step-text" data-reveal>
<div class="step-num">02</div>
<h3>Create a Room</h3>
<p>Open the extension, enter a Room ID and Password, and hit Join. Your secure synchronization space is ready.</p>
<h3><span lang="en">Create a Room</span><span lang="de">Raum erstellen</span></h3>
<p lang="en">Open the extension, enter a Room ID and Password, and hit Join. Your secure synchronization space is ready.</p>
<p lang="de">Öffnen Sie die Erweiterung, geben Sie eine Raum-ID und ein Passwort ein und klicken Sie auf Beitreten. Ihr sicherer Synchronisationsraum ist bereit.</p>
</div>
<!-- Custom Step 2 Illustration -->
<div class="step-illustration-2" data-reveal>
<div class="illus-form-box">
<div class="illus-form-field">
<div class="illus-line-placeholder" style="width: 30%; height: 6px;"></div>
<div class="illus-form-input">
<div class="illus-typing-text">brave-eagle-80</div>
</div>
</div>
<div class="illus-form-btn">
<span lang="en">CREATE ROOM</span><span lang="de">RAUM ERSTELLEN</span>
</div>
</div>
</div>
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></div>
</div>
<div class="step">
<div class="step-text" data-reveal>
<div class="step-num">03</div>
<h3>Share & Sync</h3>
<p>Send the invite link to your friends. Once they join, select your video tab and enjoy perfectly synced playback.</p>
<h3><span lang="en">Share & Sync</span><span lang="de">Teilen & Synchronisieren</span></h3>
<p lang="en">Send the invite link to your friends. Once they join, select your video tab and enjoy synchronized playback.</p>
<p lang="de">Senden Sie den Einladungslink an Ihre Freunde. Sobald sie beitreten, wählen Sie Ihren Video-Tab aus und genießen Sie die synchronisierte Wiedergabe.</p>
</div>
<!-- Custom Step 3 Illustration -->
<div class="step-illustration-3" data-reveal>
<div class="illus-players-container">
<div class="illus-mini-player">
<div class="illus-video-screen">
<div class="illus-play-icon"></div>
</div>
<div class="illus-player-timeline">
<div class="illus-timeline-bar">
<div class="illus-timeline-progress"></div>
</div>
</div>
</div>
<div class="illus-sync-connection">
<div class="illus-sync-line">
<div class="illus-sync-pulse"></div>
</div>
<span class="illus-sync-label">IN SYNC</span>
</div>
<div class="illus-mini-player">
<div class="illus-video-screen">
<div class="illus-play-icon"></div>
</div>
<div class="illus-player-timeline">
<div class="illus-timeline-bar">
<div class="illus-timeline-progress"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Self Hosters Paradise Section -->
<section id="self-hosting" style="background: rgba(15, 23, 42, 0.4); border-top: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);">
<div class="container">
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 1rem;">
<span lang="en">For Self-Hosters</span><span lang="de">Für Self-Hoster</span>
</h2>
<p style="text-align: center; color: var(--text-muted); margin-bottom: 3rem; max-width: 600px; margin-left: auto; margin-right: auto;">
<span lang="en">Maintain full data sovereignty. Deploy your own private relay server in minutes.</span>
<span lang="de">Behalte die volle Datenhoheit. Richte deinen eigenen privaten Relay-Server in wenigen Minuten ein.</span>
</p>
<div class="terminal-container" data-reveal>
<div class="terminal-header">
<div class="terminal-dots">
<span class="terminal-dot red"></span>
<span class="terminal-dot yellow"></span>
<span class="terminal-dot green"></span>
</div>
<div class="terminal-tabs">
<button class="terminal-tab-btn active" data-tab="pane-docker">docker-compose.yml</button>
<button class="terminal-tab-btn" data-tab="pane-caddy">Caddyfile</button>
</div>
</div>
<div class="terminal-body">
<button class="terminal-copy-btn">
📋 <span lang="en">Copy Code</span><span lang="de">Code kopieren</span>
</button>
<div id="pane-docker" class="terminal-pane active">
<pre><code><span class="t-key">services:</span>
<span class="t-key">koala-sync:</span>
<span class="t-key">image:</span> <span class="t-str">ghcr.io/shik3i/koalasync:latest</span>
<span class="t-key">container_name:</span> <span class="t-str">KoalaSync</span>
<span class="t-key">restart:</span> <span class="t-str">always</span>
<span class="t-comment"># Access internally by proxying to container port 3000</span>
<span class="t-key">environment:</span>
<span class="t-key">-</span> <span class="t-str">TZ=Europe/Berlin</span>
<span class="t-key">-</span> <span class="t-str">PORT=3000</span>
<span class="t-key">-</span> <span class="t-str">MIN_VERSION=1.0.0</span>
<span class="t-key">-</span> <span class="t-str">MAX_ROOMS=100</span>
<span class="t-key">-</span> <span class="t-str">MAX_PEERS_PER_ROOM=50</span>
<span class="t-key">pids_limit:</span> <span class="t-val">2048</span></code></pre>
<div style="margin-top: 1rem; font-size: 0.75rem; text-align: right; padding-right: 0.5rem;">
<a href="https://github.com/Shik3i/KoalaSync/pkgs/container/koalasync" target="_blank" style="color: var(--accent); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; font-weight: 600; transition: opacity 0.2s; opacity: 0.85;">
📦 <span lang="en">View all image tags on GitHub Packages</span><span lang="de">Alle Image-Tags auf GitHub Packages ansehen</span>
</a>
</div>
</div>
<div id="pane-caddy" class="terminal-pane">
<pre><code><span class="t-key">sync.koalastuff.net</span> {
<span class="t-comment"># Specify the web root inside the container</span>
<span class="t-key">root</span> <span class="t-val">*</span> <span class="t-str">/var/www/html</span>
<span class="t-comment"># Enable static file server to deliver HTML, CSS, JS</span>
<span class="t-key">file_server</span>
}
<span class="t-key">syncserver.koalastuff.net</span> {
<span class="t-key">reverse_proxy</span> <span class="t-val">KoalaSync:3000</span>
}</code></pre>
</div>
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></div>
</div>
</div>
</div>
@@ -120,19 +483,24 @@
<section style="text-align: center;">
<div class="container">
<h2 data-reveal>Ready to sync?</h2>
<p data-reveal style="margin-bottom: 2rem; color: var(--text-muted);">Join thousands of users watching together.</p>
<a href="https://github.com/Shik3i/KoalaSync" class="btn btn-primary" data-reveal>View on GitHub</a>
<h2 data-reveal><span lang="en">Ready to sync?</span><span lang="de">Bereit zum Synchronisieren?</span></h2>
<p data-reveal style="margin-bottom: 2rem; color: var(--text-muted);">
<span lang="en">Start watching together with friends.</span><span lang="de">Schau gemeinsam mit Freunden.</span>
</p>
<a href="https://github.com/Shik3i/KoalaSync" class="btn btn-primary" data-reveal>
<span lang="en">View on GitHub</span><span lang="de">Auf GitHub ansehen</span>
</a>
</div>
</section>
<footer>
<div class="container">
<p>&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
<p lang="en" style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
<p lang="de" style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1.5rem;">
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Legal Notice</span><span lang="de">Impressum</span></a>
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Privacy Policy</span><span lang="de">Datenschutz</span></a>
</div>
</div>
</footer>
+23 -11
View File
@@ -1,11 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beitreten | KoalaSync</title>
<title>Join Room | KoalaSync</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
<meta name="robots" content="noindex">
<script src="lang-init.js"></script>
</head>
<body>
<div class="bg-blobs">
@@ -20,19 +22,28 @@
<img src="assets/logo.png" alt="KoalaSync Logo">
<span>KoalaSync</span>
</a>
<button class="hamburger" aria-label="Menu"></button>
<div class="nav-links">
<a href="index.html">Home</a>
<a href="https://github.com/shik3i/KoalaSync" target="_blank">GitHub</a>
<a href="index.html"><span lang="de">Startseite</span><span lang="en">Home</span></a>
<a href="https://github.com/shik3i/KoalaSync" target="_blank" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
<a class="lang-toggle" style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
<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" style="width: 16px; height: 16px; display: block;"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
EN/DE
</a>
</div>
</div>
</nav>
<main class="legal-content join-card">
<div class="legal-card" id="join-container" data-reveal style="padding: 2.5rem;">
<div class="room-badge">INVITATION DETECTED</div>
<h1 id="join-title" style="font-size: 2rem; margin-bottom: 1rem;">Ready to sync?</h1>
<div class="room-badge"><span lang="en">INVITATION DETECTED</span><span lang="de">EINLADUNG ERKANNT</span></div>
<h1 id="join-title" style="font-size: 2rem; margin-bottom: 1rem;"><span lang="en">Ready to sync?</span><span lang="de">Bereit zum Synchronisieren?</span></h1>
<p id="join-desc" style="text-align: center; color: var(--text-muted); margin-bottom: 2rem; font-size: 0.9rem;">
You've been invited to join a synchronized session.
<span lang="en">You've been invited to join a synchronized session.</span>
<span lang="de">Du wurdest eingeladen, einer synchronisierten Sitzung beizutreten.</span>
</p>
<div id="room-info-box" style="background: rgba(255,255,255,0.03); padding: 2rem; border-radius: 20px; margin-bottom: 2rem; border: 1px solid var(--glass-border); text-align: center; position: relative; overflow: hidden;">
@@ -41,7 +52,7 @@
</div>
<div id="join-actions" style="display: flex; flex-direction: column; gap: 1rem;">
<div style="text-align: center; color: var(--text-muted); font-size: 0.8rem;">Detecting extension...</div>
<div style="text-align: center; color: var(--text-muted); font-size: 0.8rem;"><span lang="en">Detecting extension...</span><span lang="de">Erweiterung wird erkannt...</span></div>
</div>
</div>
</main>
@@ -49,10 +60,11 @@
<footer>
<div class="container">
<p>&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
<p lang="en" style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
<p lang="de" style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1.5rem;">
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Legal Notice</span><span lang="de">Impressum</span></a>
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Privacy Policy</span><span lang="de">Datenschutz</span></a>
</div>
</div>
</footer>
+26
View File
@@ -0,0 +1,26 @@
(function() {
var savedLang = localStorage.getItem('koala_lang');
var browserLang = navigator.language.startsWith('de') ? 'de' : 'en';
var activeLang = savedLang || browserLang;
document.documentElement.classList.add('lang-' + activeLang);
document.documentElement.lang = activeLang;
// Update titles dynamically based on page
var path = window.location.pathname;
var isIndex = path === '/' || path.endsWith('index.html') || path.split('/').pop() === '';
var isJoin = path.endsWith('join.html');
if (isIndex) {
var titles = {
en: 'KoalaSync | Real-time Video Synchronization for Friends',
de: 'KoalaSync | Echtzeit-Video-Synchronisation für Freunde'
};
document.title = titles[activeLang] || titles.en;
} else if (isJoin) {
var titles = {
en: 'Join Room | KoalaSync',
de: 'Raum beitreten | KoalaSync'
};
document.title = titles[activeLang] || titles.en;
}
})();
+6
View File
@@ -0,0 +1,6 @@
# KoalaSync Website — Allow all crawlers, full indexing
User-agent: *
Allow: /
# Sitemap for search engines
Sitemap: https://sync.koalastuff.net/sitemap.xml
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://sync.koalastuff.net/</loc>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/impressum.html</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/datenschutz.html</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+839 -28
View File
@@ -156,42 +156,45 @@ nav {
display: flex;
align-items: center;
padding-top: 8rem;
text-align: center;
padding-bottom: 4rem;
}
.hero-grid {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1.2fr 1fr;
align-items: center;
gap: 2rem;
gap: 4rem;
text-align: left;
width: 100%;
}
.hero-text h1 {
font-size: 5rem;
font-size: 4.5rem;
font-weight: 800;
line-height: 1;
line-height: 1.1;
margin-bottom: 1.5rem;
background: linear-gradient(to bottom, #fff 30%, #6366f1);
background: linear-gradient(to bottom, #fff 40%, #6366f1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero-text p {
font-size: 1.5rem;
.hero-text p, .hero-subtitle {
font-size: 1.25rem;
color: var(--text-muted);
margin-bottom: 3rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
margin-bottom: 2.5rem;
max-width: 600px;
font-weight: 400;
line-height: 1.6;
}
.cta-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.btn {
padding: 1rem 2rem;
padding: 0.85rem 1.75rem;
border-radius: 12px;
font-weight: 600;
text-decoration: none;
@@ -199,6 +202,7 @@ nav {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
}
.btn-primary {
@@ -212,6 +216,18 @@ nav {
box-shadow: 0 20px 25px -5px var(--accent-glow);
}
.btn-firefox {
background: #e66000;
color: white;
box-shadow: 0 10px 15px -3px rgba(230, 96, 0, 0.3);
}
.btn-firefox:hover {
background: #ff7300;
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(230, 96, 0, 0.4);
}
.btn-secondary {
background: var(--card);
color: var(--text);
@@ -227,9 +243,9 @@ nav {
display: inline-block;
background: rgba(99, 102, 241, 0.1);
color: var(--accent);
padding: 0.5rem 1.25rem;
padding: 0.4rem 1rem;
border-radius: 99px;
font-size: 0.85rem;
font-size: 0.8rem;
font-weight: 700;
margin-bottom: 1.5rem;
border: 1px solid rgba(99, 102, 241, 0.3);
@@ -237,15 +253,339 @@ nav {
text-transform: uppercase;
}
/* --- Interactive CSS Extension Mockup --- */
.hero-mockup-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.hero-image {
.extension-mockup {
width: 320px;
height: 490px;
background: #0f172a;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), 0 0 20px rgba(99, 102, 241, 0.15);
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
position: relative;
user-select: none;
}
.mock-header {
background: #1e293b;
padding: 0.85rem 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.mock-header img {
width: 20px;
height: 20px;
}
.mock-header span {
font-weight: 800;
font-size: 0.95rem;
letter-spacing: 0.5px;
color: var(--text);
}
.mock-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
background: #1e293b;
padding: 0.25rem;
gap: 0.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.mock-tab {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
padding: 0.4rem 0;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.mock-tab:hover {
color: var(--text);
}
.mock-tab.active {
background: var(--accent);
color: white;
}
.mock-body {
flex: 1;
overflow-y: auto;
padding: 0.95rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
font-size: 0.8rem;
scrollbar-width: none; /* Firefox */
}
.mock-body::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.mock-screen {
display: none;
flex-direction: column;
gap: 0.85rem;
height: 100%;
}
.mock-screen.active {
display: flex;
}
/* Common Mockup Components */
.mock-card {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 0.75rem;
}
.mock-label {
font-size: 0.65rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
.mock-input {
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
color: white;
padding: 0.45rem;
font-size: 0.75rem;
width: 100%;
outline: none;
}
.mock-btn {
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem;
font-weight: 600;
font-size: 0.75rem;
cursor: pointer;
transition: background 0.2s;
text-align: center;
}
.mock-btn:hover {
background: #4f46e5;
}
/* Room Tab Details */
.mock-joined-room {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 8px;
padding: 0.6rem 0.75rem;
}
.mock-room-name {
font-weight: 700;
color: var(--accent);
font-size: 0.85rem;
}
.mock-server-badge {
background: rgba(99, 102, 241, 0.2);
color: var(--accent);
font-size: 0.6rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
.mock-invite-box {
display: flex;
gap: 0.4rem;
}
.mock-invite-box input {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mock-peer-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mock-peer-item {
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 0.5rem 0.6rem;
}
.mock-peer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.mock-peer-name {
font-weight: 700;
color: var(--text);
}
.mock-peer-meta {
font-size: 0.65rem;
color: var(--text-muted);
}
.mock-peer-tab {
font-size: 0.7rem;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mock-peer-status {
font-size: 0.65rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Sync Tab Details */
.mock-target-box {
display: flex;
align-items: center;
gap: 0.4rem;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.2);
color: #facc15;
padding: 0.5rem 0.6rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.7rem;
}
.mock-ctrl-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.mock-btn-play { background: #22c55e; }
.mock-btn-play:hover { background: #16a34a; }
.mock-btn-pause { background: #ef4444; }
.mock-btn-pause:hover { background: #dc2626; }
.mock-btn-force { background: var(--accent); grid-column: span 2; }
.mock-status-display {
display: flex;
align-items: center;
justify-content: space-between;
}
.mock-status-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #22c55e;
padding: 2px 8px;
border-radius: 99px;
font-size: 0.65rem;
font-weight: 700;
}
.mock-log-item {
font-size: 0.7rem;
color: var(--text-muted);
border-left: 2px solid var(--accent);
padding-left: 0.4rem;
margin-top: 0.4rem;
}
/* Settings Tab Details */
.mock-settings-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.mock-settings-row input[type="checkbox"] {
margin-top: 0.2rem;
cursor: pointer;
}
.mock-settings-row label {
cursor: pointer;
font-weight: 600;
}
.mock-settings-row p {
font-size: 0.65rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
/* Dev Tab Details */
.mock-diagnostic-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
}
.mock-diag-box {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 6px;
padding: 0.4rem;
}
.mock-diag-box .diag-val {
font-family: monospace;
font-weight: 700;
color: var(--accent);
font-size: 0.75rem;
}
/* --- Features --- */
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 2rem;
margin-top: 4rem;
}
@@ -255,17 +595,33 @@ nav {
padding: 2.5rem;
border-radius: 24px;
border: 1px solid var(--glass-border);
transition: transform 0.3s;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.feature-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: 24px;
padding: 1px;
background: linear-gradient(to bottom right, rgba(99, 102, 241, 0.25), transparent);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.feature-card:hover {
transform: translateY(-10px);
transform: translateY(-5px);
box-shadow: 0 15px 30px rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.2);
}
.feature-icon {
width: 48px;
height: 48px;
background: var(--accent-glow);
background: rgba(99, 102, 241, 0.1);
color: var(--accent);
display: flex;
align-items: center;
@@ -273,28 +629,32 @@ nav {
border-radius: 12px;
margin-bottom: 1.5rem;
font-size: 1.5rem;
border: 1px solid rgba(99, 102, 241, 0.2);
}
.feature-card h3 {
margin-bottom: 1rem;
font-size: 1.5rem;
margin-bottom: 0.75rem;
font-size: 1.35rem;
font-weight: 700;
}
.feature-card p {
color: var(--text-muted);
font-size: 0.95rem;
line-height: 1.5;
}
/* --- How it works --- */
.steps {
display: flex;
flex-direction: column;
gap: 4rem;
gap: 6rem;
}
.step {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
grid-template-columns: 1fr 1.1fr;
gap: 6rem;
align-items: center;
}
@@ -303,11 +663,412 @@ nav {
}
.step-num {
font-size: 5rem;
font-size: 4rem;
font-weight: 800;
color: var(--accent-glow);
color: rgba(99, 102, 241, 0.15);
line-height: 1;
margin-bottom: 0.5rem;
}
.step-text h3 {
font-size: 1.85rem;
margin-bottom: 1rem;
font-weight: 800;
}
.step-text p {
color: var(--text-muted);
font-size: 1.05rem;
line-height: 1.6;
}
/* Custom CSS Mockups for step illustrations */
.step-illustration-1, .step-illustration-2, .step-illustration-3 {
background: #1e293b;
height: 240px;
border-radius: 20px;
border: 1px solid var(--glass-border);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.3);
}
/* Step 1 Illustration: Browser & Extension icon trigger */
.illus-browser-bar {
width: 85%;
background: #0f172a;
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 10px 20px rgba(0,0,0,0.4);
}
.illus-browser-header {
background: #1e293b;
padding: 0.5rem 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.illus-dots {
display: flex;
gap: 4px;
}
.illus-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
}
.illus-address-bar {
background: #0f172a;
border: 1px solid rgba(255,255,255,0.05);
padding: 2px 10px;
border-radius: 4px;
width: 60%;
height: 14px;
}
.illus-extension-icon {
position: relative;
width: 20px;
height: 20px;
background: rgba(99, 102, 241, 0.2);
border: 1px solid var(--accent);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
animation: pulse-border 2s infinite alternate;
}
@keyframes pulse-border {
0% { box-shadow: 0 0 0 rgba(99, 102, 241, 0); border-color: rgba(99, 102, 241, 0.4); }
100% { box-shadow: 0 0 8px rgba(99, 102, 241, 0.6); border-color: var(--accent); }
}
.illus-browser-body {
padding: 1.5rem;
height: 80px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.illus-line-placeholder {
height: 8px;
background: rgba(255,255,255,0.05);
border-radius: 4px;
}
/* Step 2 Illustration: Typing in Form Fields */
.illus-form-box {
width: 70%;
display: flex;
flex-direction: column;
gap: 0.75rem;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1rem;
backdrop-filter: blur(10px);
}
.illus-form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.illus-form-input {
background: #0f172a;
border: 1px solid rgba(99, 102, 241, 0.3);
padding: 0.45rem;
border-radius: 6px;
height: 28px;
position: relative;
overflow: hidden;
}
.illus-typing-text {
font-family: monospace;
font-weight: 700;
color: var(--text);
font-size: 0.8rem;
display: flex;
align-items: center;
}
.illus-typing-text::after {
content: '|';
animation: blink-cursor 0.8s infinite;
color: var(--accent);
}
@keyframes blink-cursor {
50% { opacity: 0; }
}
.illus-form-btn {
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
padding: 0.45rem;
font-weight: 700;
font-size: 0.75rem;
text-align: center;
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.3);
}
/* Step 3 Illustration: Multi-player Sync simulation */
.illus-players-container {
display: flex;
align-items: center;
gap: 1.5rem;
width: 90%;
}
.illus-mini-player {
flex: 1;
background: #0f172a;
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
height: 120px;
position: relative;
overflow: hidden;
}
.illus-video-screen {
width: 100%;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle, rgba(99,102,241,0.15) 0%, transparent 80%);
}
.illus-play-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(99, 102, 241, 0.9);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
box-shadow: 0 0 12px rgba(99, 102, 241, 0.5);
position: relative;
}
.illus-player-timeline {
height: 20px;
background: #1e293b;
display: flex;
align-items: center;
padding: 0 0.5rem;
border-top: 1px solid rgba(255,255,255,0.05);
}
.illus-timeline-bar {
flex: 1;
height: 3px;
background: rgba(255,255,255,0.1);
position: relative;
border-radius: 2px;
}
.illus-timeline-progress {
position: absolute;
top: 0; left: 0;
width: 60%;
height: 100%;
background: var(--accent);
border-radius: 2px;
animation: timeline-grow 4s infinite linear;
}
@keyframes timeline-grow {
0% { width: 0%; }
100% { width: 100%; }
}
.illus-sync-connection {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
color: var(--accent);
font-size: 1.25rem;
}
.illus-sync-line {
width: 40px;
height: 2px;
background: linear-gradient(to right, var(--accent), var(--success));
position: relative;
}
.illus-sync-pulse {
position: absolute;
top: -3px;
left: 0;
width: 8px;
height: 8px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 8px #fff;
animation: slide-pulse 2s infinite linear;
}
@keyframes slide-pulse {
0% { left: 0%; }
100% { left: 100%; }
}
.illus-sync-label {
font-size: 0.55rem;
font-weight: 700;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid rgba(34, 197, 94, 0.25);
white-space: nowrap;
}
/* --- Self Hosters Terminal --- */
.terminal-container {
max-width: 850px;
margin: 3rem auto 0 auto;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 45px rgba(0,0,0,0.5);
text-align: left;
}
.terminal-header {
background: #1e293b;
padding: 0.85rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.terminal-dots {
display: flex;
gap: 6px;
}
.terminal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.terminal-dot.red { background: #ef4444; }
.terminal-dot.yellow { background: #f59e0b; }
.terminal-dot.green { background: #10b981; }
.terminal-tabs {
display: flex;
gap: 0.5rem;
}
.terminal-tab-btn {
background: none;
border: none;
color: var(--text-muted);
font-weight: 600;
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.terminal-tab-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.03);
}
.terminal-tab-btn.active {
color: var(--accent);
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2);
}
.terminal-body {
position: relative;
padding: 1.5rem;
}
.terminal-pane {
display: none;
margin: 0;
}
.terminal-pane.active {
display: block;
}
.terminal-pane pre {
margin: 0;
overflow-x: auto;
}
.terminal-pane code {
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 0.85rem;
line-height: 1.6;
color: #e2e8f0;
}
/* Syntax Highlighting */
.t-comment { color: #64748b; font-style: italic; }
.t-key { color: #f472b6; }
.t-val { color: #38bdf8; }
.t-str { color: #34d399; }
.t-num { color: #fbbf24; }
.terminal-copy-btn {
position: absolute;
top: 1rem;
right: 1.5rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-muted);
padding: 0.35rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.3rem;
}
.terminal-copy-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
border-color: rgba(255, 255, 255, 0.15);
}
.terminal-copy-btn:active {
transform: scale(0.97);
}
/* --- Footer --- */
@@ -330,6 +1091,7 @@ footer {
transform: translateY(0);
}
/* --- Legal Pages --- */
.legal-content {
max-width: 800px;
@@ -398,11 +1160,60 @@ footer {
}
.cta-group {
justify-content: center;
flex-wrap: wrap;
}
.nav-links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
flex-direction: column;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(12px);
padding: 1rem 2rem;
gap: 1rem;
border-bottom: 1px solid var(--glass-border);
}
.nav-links.open {
display: flex;
}
.hamburger {
display: flex;
}
.legal-card {
padding: 1.5rem;
}
}
/* --- Language Toggle --- */
html.lang-en [lang="de"],
html.lang-de [lang="en"] {
display: none !important;
}
.lang-toggle {
cursor: pointer;
font-weight: 600;
user-select: none;
color: var(--text-muted);
transition: color 0.3s;
}
.lang-toggle:hover {
color: var(--accent);
}
/* --- Hamburger Menu --- */
.hamburger {
display: none;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text);
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
+4
View File
@@ -0,0 +1,4 @@
{
"version": "1.9.0",
"date": "2026-05-28T02:55:04Z"
}