Compare commits

..

102 Commits

Author SHA1 Message Date
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
34 changed files with 4070 additions and 675 deletions
+15
View File
@@ -59,6 +59,7 @@ jobs:
- name: Inject version into source files
run: |
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
@@ -73,8 +74,22 @@ jobs:
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
+21 -5
View File
@@ -68,7 +68,7 @@ The following features are critical and must not be removed or fundamentally alt
- **Two-Phase Force Sync**: The `Prepare``ACK``Execute` flow ensures all peers are buffered before playback resumes.
- **Episode Auto-Sync**: Ensures series binges stay perfectly synced. A lobby initiates during title transitions, freezing peers until everyone is ready.
- **Dual Heartbeat**:
- **Background Heartbeat (30s)**: Ensures session persistence even without a video element.
- **Background Heartbeat (1m)**: Ensures session persistence even without a video element.
- **Content Heartbeat (15s)**: Transmits current video metadata (time, title).
- **Dead Peer Pruning**: Server "Reaper" disconnects peers after 5 minutes of total silence (no heartbeats or events).
- **Deduplication**: Server kills old sockets if a user re-joins with the same `peerId` to prevent ghosts.
@@ -94,14 +94,30 @@ The following features are critical and must not be removed or fundamentally alt
## 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.
1. Commit all code changes and push to `main`.
2. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
3. The CI will extract the version from the tag (e.g., `v1.4.0``1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release.
4. Verify the release builds on GitHub Actions.
> - **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`.
+10 -1
View File
@@ -7,8 +7,17 @@ sync.koalastuff.net {
file_server
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
+1 -2
View File
@@ -15,8 +15,7 @@ KoalaSync does not use a database. All active session data exists only in the se
|:----------|:------------------|:---------------------|
| Session data (peerId, username, video metadata) | Duration of session | User leaves room or disconnects |
| Room state | 2 hours max | Last peer leaves, or inactivity timeout |
| Failed auth lockout records | 15 minutes | Automatic expiry |
| Auth failure records | 1 hour | Periodic cleanup |
| Auth failure records (lockout after 5 failed attempts) | 15 minutes | Periodic cleanup |
| Connection rate-limit counters | 60 seconds | Automatic expiry |
| Event rate-limit counters | 10 seconds | Automatic expiry + periodic cleanup |
+29 -16
View File
@@ -1,18 +1,19 @@
# KoalaSync
<h1 align="center"><img src="extension/icons/icon128.png" width="32" valign="middle"> KoalaSync</h1>
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/github/v/release/Shik3i/KoalaSync" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Shik3i/KoalaSync?color=blue" alt="License"></a>
<img src="https://img.shields.io/badge/Browser-Chrome%20|%20Firefox-blueviolet" alt="Cross Browser">
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
</p>
KoalaSync is a premium, lightweight Browser Extension and Relay Server for synchronized video playback across any website—YouTube, Twitch, Netflix, and custom HTML5 players. Built with a focus on **Data Sovereignty** and **Extreme Performance**.
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback across any website—YouTube, Twitch, Netflix, and custom HTML5 players. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
### 🌟 Why KoalaSync?
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture.
* **📡 Direct Logic**: Custom wire protocol implementation for frame-perfect synchronization.
* **📡 Direct Logic**: Manual Socket.IO wire implementation for reliable synchronization.
* **🛠️ Clean Build**: Dependency-free extension runtime with no library overhead.
* **🌐 Universal**: Works on any website with a `<video>` tag.
@@ -24,12 +25,33 @@ KoalaSync is a premium, lightweight Browser Extension and Relay Server for synch
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together.
- **Smart Matching**: Automatically highlights tabs containing matching video titles.
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
- **Zero-Latency Relay**: Custom wire protocol implementation for maximum performance.
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
- **Seamless Invitations**: Smart links that automatically configure server and room credentials for your friends.
---
### 📂 Repository Structure
### 🚀 Quick Start
#### For Users (Installation & Usage)
The easiest and safest way to install KoalaSync is directly through the official browser stores:
<p>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white&style=for-the-badge" alt="Chrome Extension"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white&style=for-the-badge" alt="Firefox Add-on"></a>
</p>
*(For manual offline installation: Download the latest `.zip` from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page and load it as an "Unpacked Extension" in Developer Mode).*
**How to use:**
1. **Create a Room:** Click the Koala icon in your browser and hit `+ Create New Room`.
2. **Invite Friends:** Share the auto-copied invite link. Once they click it, they automatically join.
3. **Pick a Video:** Navigate to the Sync Tab, select the tab playing your video, and grab some popcorn! 🍿
---
### 🛠️ For Developers & Self-Hosters
#### 📂 Repository Structure
- `extension/`: Browser Extension (Chrome & Firefox).
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
@@ -38,16 +60,7 @@ KoalaSync is a premium, lightweight Browser Extension and Relay Server for synch
- `scripts/`: Automated build and synchronization utilities.
- `docs/`: Technical deep-dives ([Architecture](docs/ARCHITECTURE.md), [Sync Guide](docs/SYNC_GUIDE.md)).
---
### 🚀 Quick Start
#### For Users (Installation)
The easiest way to install KoalaSync is to download the pre-compiled version from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page.
1. Download the latest `koalasync-chrome.zip` or `koalasync-firefox.zip`.
2. Extract the file and load it as an "Unpacked Extension" in your browser's Developer Mode.
#### For Developers (Building)
#### Building from Source
To build the extension from source and synchronize protocol constants:
```bash
npm install
+14 -7
View File
@@ -11,18 +11,19 @@ This document describes the communication flows and internal logic of the KoalaS
- **Protocol Version**: Client must match the server's protocol (currently `1.0.0`).
3. Server responds with Engine.IO handshake (`0`) and the client joins the namespace (`40`).
- **Room Join**: Background emits `JOIN_ROOM` containing `roomId`, `password`, `peerId`, and `username`.
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers".
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers". Deduplication re-validates after acquiring the room creation lock to avoid kicking the wrong socket during concurrent joins.
## 2. Media Event Synchronization
When a user interacts with a video:
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element.
2. **Prevention of Loops**: Uses an `expectedEvents` Set to distinguish between user actions and programmatic actions. Expected events are consumed on match and expire via timeout.
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element, including videos inside Shadow DOM (YouTube, Netflix, etc.).
2. **Prevention of Loops**: Uses an `expectedEvents` Set to distinguish between user actions and programmatic actions. Expected events are consumed on match and expire via timeout. Timeout IDs are cleaned up immediately to prevent memory leaks.
3. **Reporting**: `content.js` sends a `CONTENT_EVENT` to `background.js`.
4. **Relay**: The Server forwards the event to all other peers in the room.
5. **Execution**: Remote peers receive the command and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
5. **Execution**: Remote peers receive the command via `SERVER_COMMAND` (which includes the original `senderId` for correct ACK routing) and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
6. **ACK Routing**: `content.js` echoes the `commandSenderId` back in `CMD_ACK`, ensuring the `EVENT_ACK` is routed to the correct initiating peer even when multiple commands arrive concurrently.
## 3. Two-Phase Force Sync
Ensures all peers are frame-perfect and buffered before resuming:
Ensures all peers are buffered and synchronized before resuming:
1. **Prepare**: Initiator sends `FORCE_SYNC_PREPARE` with the target timestamp.
2. **Buffer**: Peers seek and pause. Once buffered (`readyState >= 3`), they send a `FORCE_SYNC_ACK`. (Note: `content.js` limits polling to 8000ms).
3. **Execute**: Once the Initiator collects ACKs (or after an 8.5s timeout), they send `FORCE_SYNC_EXECUTE`.
@@ -44,6 +45,7 @@ To maintain a clean room state and eliminate "Ghost Peers":
- **Video Heartbeat (Content)**: Every 15 seconds, `content.js` sends current playback metadata (time, title, state) if a video is found.
- **Server Pruning**: The server runs a "Reaper" every 2 minutes. If a peer has sent **zero** activity (no events and no heartbeats) for 5 minutes, they are forcefully disconnected.
- **Immediate Cleanup**: Rooms are deleted instantly when the last peer leaves or disconnects.
- **Reconnect Strategy**: Aggressive backoff — 500ms base, 1.5x multiplier, capped at 5s. Max 20 attempts before marking as failed. Events are queued during disconnect and flushed after namespace rejoin.
> [!CAUTION]
> **Identity Rule**: Differentiate between `peerId` and `socket.id`. Use `socket.id` exclusively for ephemeral transport routing on the server. Use `peerId` exclusively for identity, state management, and room tracking across the stack.
@@ -54,8 +56,13 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
- **Storm Prevention**: When dispatching state updates in response to a new user joining (e.g., an active lobby state), ensure ONLY the initiator (or a designated leader) calls `emit()` to prevent $O(N)$ broadcast storms.
## 7. Security & Stability
- **Service Worker Lifecycle**: Uses `chrome.alarms` to prevent the Manifest V3 service worker from suspending while in an active room.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS.
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or 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.
+4 -4
View File
@@ -10,7 +10,7 @@ This guide walks through the complete user flow of KoalaSync, from creating a ro
2. The extension adds a small icon to your browser toolbar.
3. On first install, a unique 8-character **Peer ID** is generated locally and stored in `chrome.storage.local`. This ID is never sent to any external service — it only travels to the relay server when you join a room.
> **What's stored locally**: `peerId` (8-char hex), `username` (customizable), `serverUrl`, `filterNoise` preference. All stored via `chrome.storage.sync` and `chrome.storage.local`.
> **What's stored locally**: `peerId` (8-char hex), `username` (customizable, defaults to a readable adjective-noun pair), `serverUrl`, `filterNoise` preference. All stored via `chrome.storage.sync` and `chrome.storage.local`.
---
@@ -109,7 +109,7 @@ Both users now need to select which browser tab contains the video to sync:
4. Tabs with a **matching video title** are highlighted with a ⭐ prefix for easy identification.
5. Selecting a tab causes `background.js` to set `currentTabId` and inject `content.js` into that tab via `chrome.scripting.executeScript`.
> **What `content.js` does on injection**: Finds the first `<video>` element on the page and attaches event listeners for `play`, `pause`, `seeked`, `timeupdate`, and `volumechange`. It uses an `expectedEvents` Set to distinguish between user actions and programmatic actions (loop prevention).
> **What `content.js` does on injection**: Finds the first `<video>` element on the page and attaches event listeners for `play`, `pause`, `seeked`, and `loadeddata`. (Time and volume state are tracked via a 15-second heartbeat interval, not continuous event listeners). It uses an `expectedEvents` Set to distinguish between user actions and programmatic actions (loop prevention).
---
@@ -143,7 +143,7 @@ If videos drift out of sync, either user can click **"Force Sync"**:
### Phase 2 — Execute
6. Once all ACKs are received (or after 8.5 seconds), the initiator emits `FORCE_SYNC_EXECUTE`.
7. All peers call `video.play()` simultaneously, achieving frame-perfect sync.
7. All peers call `video.play()` simultaneously, achieving synchronized playback.
> **Why two phases?** Without buffering confirmation, peers with slower connections would start playing before they've loaded the target timestamp, causing immediate desync.
@@ -155,7 +155,7 @@ While in a room, two heartbeats keep the session alive:
| Heartbeat | Interval | Source | Purpose |
|:----------|:---------|:-------|:--------|
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" even without a video |
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" and triggers aggressive reconnect (500ms base, max 5s) |
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, `mediaTitle`, `playbackState`, `volume`, `muted` |
- **Server Reaper**: Every 2 minutes, the server checks for peers with no activity for 5+ minutes and disconnects them ("dead peer pruning").
+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.
+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"
}
}
}
];
+1 -1
View File
@@ -20,7 +20,7 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
- **No Browsing History**: We do not track or store your browsing history.
- **State Management**: Sensitive data (Room Passwords) is stored locally using `chrome.storage`.
- **Zero Telemetry**: No analytics or external tracking scripts.
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring maximum performance and privacy.
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring performance and privacy.
## Installation
1. **Prepare Extension**: From the repository root, run:
+314 -97
View File
@@ -1,13 +1,10 @@
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT } from './shared/constants.js';
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
// --- State Management ---
let socket = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let isConnecting = false;
let peerId = null; // initialized via getPeerId()
let currentRoom = null;
let lastPeersJson = null;
let currentTabId = null;
let currentTabTitle = null; // New: for Smart Matching
let logs = [];
@@ -18,7 +15,6 @@ let pendingHistory = [];
let eventQueue = [];
let isNamespaceJoined = false;
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
let currentCommandSenderId = null; // Track who sent the last command we are executing
// --- Boot Sequence Lock ---
let restorationTask = null;
@@ -26,12 +22,22 @@ let restorationTask = null;
function ensureState() {
if (!restorationTask) {
restorationTask = new Promise(resolve => {
let resolved = false;
const done = () => { if (!resolved) { resolved = true; resolve(); } };
const storageTimeout = setTimeout(() => {
addLog('Storage restoration timed out, continuing with defaults', 'warn');
storageInitialized = true;
done();
}, 10000);
chrome.storage.session.get([
'logs', 'history', 'currentRoom', 'lastActionState',
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
'episodeLobby'
], (data) => {
clearTimeout(storageTimeout);
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
// Merge data from storage with any early-arriving state
@@ -92,7 +98,7 @@ function ensureState() {
pendingHistory = [];
}
resolve();
done();
});
});
}
@@ -103,8 +109,12 @@ function ensureState() {
ensureState();
let reconnectTimer = null;
let reconnectStartTime = null; // New: track when reconnection started
let reconnectFailed = false; // New: true if we hit the 5-min cap
let reconnectStartTime = null;
let reconnectFailed = false;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 20;
const _RECONNECT_BASE_DELAY = 500;
const _RECONNECT_MAX_DELAY = 5000;
// Force Sync Coordination
let isForceSyncInitiator = false;
@@ -137,6 +147,28 @@ function createPeerData(raw) {
};
}
/**
* Updates properties of a peer in the room and instantly broadcasts the changes to the popup UI.
* Also tracks lastReactiveUpdate to guard against older heartbeats in transit overwriting state.
*/
function updateLocalPeerState(targetPeerId, updates) {
if (!currentRoom || !Array.isArray(currentRoom.peers)) return;
const peer = currentRoom.peers.find(p => typeof p === 'object' ? p.peerId === targetPeerId : p === targetPeerId);
if (peer && typeof peer === 'object') {
Object.keys(updates).forEach(key => {
if (updates[key] !== undefined && updates[key] !== null) {
peer[key] = updates[key];
}
});
peer.lastReactiveUpdate = Date.now(); // Race condition guard lock
if (updates.currentTime !== undefined && updates.currentTime !== null) {
peer.lastHeartbeat = Date.now(); // reset time interpolation baseline
}
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
}
async function getPeerId() {
const data = await chrome.storage.local.get(['peerId']);
if (data.peerId) return data.peerId;
@@ -146,15 +178,35 @@ async function getPeerId() {
}
async function getSettings() {
return new Promise(resolve => {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username: data.username || ''
});
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
let username = data.username;
if (!username) {
const adjs = ['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'];
const 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'];
username = `${adjs[Math.floor(Math.random() * adjs.length)]}${nouns[Math.floor(Math.random() * nouns.length)]}`;
chrome.storage.sync.set({ username }, () => {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username: username
});
});
} else {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
username: username
});
}
});
});
}
@@ -216,7 +268,7 @@ async function connect() {
return;
}
broadcastConnectionStatus('connecting');
broadcastConnectionStatus('reconnecting');
const isCustomServer = settings.serverUrl && settings.useCustomServer;
finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
@@ -239,7 +291,7 @@ async function connect() {
throw new Error(`[URL Error] ${e.message}`);
}
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}...`, 'info');
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}... (attempt ${reconnectAttempts + 1})`, 'info');
// --- Phase 4: WebSocket Init ---
try {
@@ -257,10 +309,11 @@ async function connect() {
// --- Phase 5: Event Listeners ---
socket.onopen = () => {
reconnectDelay = 1000;
reconnectAttempts = 0;
addLog('WebSocket Connection Opened', 'success');
reconnectStartTime = null;
reconnectFailed = false;
chrome.storage.session.set({ reconnectFailed: false });
isNamespaceJoined = false;
socket.send('40');
};
@@ -299,8 +352,12 @@ async function connect() {
} else if (msg.startsWith('42')) {
try {
const payload = JSON.parse(msg.substring(2));
handleServerEvent(payload[0], payload[1]);
} catch (e) {
try {
handleServerEvent(payload[0], payload[1]);
} catch (handlerErr) {
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
}
} catch (_e) {
addLog(`Failed to parse message: ${msg}`, 'error');
}
}
@@ -310,7 +367,6 @@ async function connect() {
isConnecting = false;
isNamespaceJoined = false;
// Clear Force Sync state
isForceSyncInitiator = false;
forceSyncAcks.clear();
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
@@ -319,20 +375,22 @@ async function connect() {
forceSyncAcks: [],
forceSyncDeadline: null
});
if (currentRoom) {
currentRoom.peers = [];
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
addLog(`Disconnected. Retrying in ${reconnectDelay / 1000}s...`, 'warn');
addLog('Disconnected. Scheduling reconnect...', 'warn');
socket = null;
scheduleReconnect();
};
socket.onerror = (err) => {
socket.onerror = () => {
broadcastConnectionStatus('disconnected');
addLog(`WebSocket Error: ${err.message || 'Handshake failed or server unreachable'}`, 'error');
socket.close();
addLog('WebSocket Error: Connection failed', 'error');
};
} catch (e) {
@@ -351,11 +409,15 @@ function broadcastConnectionStatus(status) {
function updateBadgeStatus() {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
if (reconnectFailed) {
chrome.action.setBadgeText({ text: 'ERR' });
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
} else if (status === 'reconnecting') {
chrome.action.setBadgeText({ text: '...' });
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
} else if (status === 'connecting') {
chrome.action.setBadgeText({ text: '...' });
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
@@ -368,48 +430,73 @@ function updateBadgeStatus() {
}
function showNotification(senderName, action) {
const label = action === 'play' ? 'started playback' :
action === 'pause' ? 'paused playback' :
action === 'seek' ? 'seeked the video' :
action === 'force_sync_execute' ? 'synchronized everyone' : action;
// Find username in current room if available
let displayName = senderName || 'A peer';
if (currentRoom && currentRoom.peers) {
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
if (peer && peer.username) displayName = peer.username;
}
chrome.storage.sync.get(['browserNotifications'], (settings) => {
if (!settings.browserNotifications) return;
chrome.notifications.create(`sync_${Date.now()}`, {
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'KoalaSync',
message: `${displayName} ${label}.`,
priority: 1
const label = action === 'play' ? 'started playback' :
action === 'pause' ? 'paused playback' :
action === 'seek' ? 'seeked the video' :
action === 'force_sync_prepare' ? 'started force sync' :
action === 'force_sync_execute' ? 'synchronized everyone' : action;
let displayName = senderName || 'A peer';
if (currentRoom && Array.isArray(currentRoom.peers)) {
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
if (peer && peer.username) displayName = peer.username;
}
chrome.notifications.create(`sync_${Date.now()}`, {
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'KoalaSync',
message: `${displayName} ${label}.`,
priority: 1
});
});
}
function scheduleReconnect() {
if (reconnectTimer || reconnectFailed) return;
if (reconnectTimer) return;
if (reconnectFailed) {
return;
}
if (!reconnectStartTime) reconnectStartTime = Date.now();
// Check 5 minute cap (300,000ms)
if (Date.now() - reconnectStartTime > 300000) {
reconnectFailed = true;
chrome.storage.session.set({ reconnectFailed: true });
addLog('Reconnection failed after 5 minutes. Please try again manually.', 'error');
addLog('Reconnection failed after 5 minutes.', 'error');
broadcastConnectionStatus('reconnect_failed');
return;
}
reconnectAttempts++;
// Cap at max attempts to prevent infinite loops
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
reconnectFailed = true;
chrome.storage.session.set({ reconnectFailed: true });
addLog('Reconnection failed after max attempts.', 'error');
broadcastConnectionStatus('reconnect_failed');
return;
}
// Aggressive reconnect: 500ms base, cap at 5s, no exponential growth beyond that
const delay = Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
}, delay);
}
// Slow reconnect logic is now handled in the keepAlive alarm
function emit(event, data) {
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
const msg = `42${JSON.stringify([event, data])}`;
@@ -445,8 +532,13 @@ function handleServerEvent(event, data) {
switch (event) {
case EVENTS.ROOM_DATA:
currentRoom = data;
if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
} else if (currentRoom) {
currentRoom.peers = [];
}
if (storageInitialized) chrome.storage.session.set({ currentRoom });
addLog(`Joined Room: ${data.roomId}`, 'success');
addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
// Inform Website Bridge & Popup
@@ -488,6 +580,14 @@ function handleServerEvent(event, data) {
addToHistory(event, data.senderId);
showNotification(data.senderId, event);
updateLastAction(event, data.senderId);
lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
// Remote Reactive Update
updateLocalPeerState(data.senderId, {
playbackState: event === EVENTS.PLAY ? 'playing' : (event === EVENTS.PAUSE ? 'paused' : undefined),
currentTime: data.currentTime !== undefined ? data.currentTime : (data.targetTime !== undefined ? data.targetTime : undefined)
});
}
routeToContent(event, data);
break;
@@ -505,24 +605,46 @@ function handleServerEvent(event, data) {
if (storageInitialized) chrome.storage.session.set({ lastActionState });
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
}
// Force Sync ACK Reactive Update
updateLocalPeerState(data.senderId, {
playbackState: 'paused', // Preparing for force sync always pauses the player
currentTime: lastActionState.targetTime
});
}
// Check if all peers responded
const peerCount = currentRoom ? currentRoom.peers.length : 1;
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
if (forceSyncAcks.size >= peerCount) {
executeForceSync();
}
}
break;
case EVENTS.FORCE_SYNC_EXECUTE:
if (data.senderId) {
if (data?.senderId) {
addToHistory(event, data.senderId);
showNotification(data.senderId, event);
// (The sender's state is updated below with everyone else)
}
// Force Sync Execute Remote Reactive Update:
// Set all peers to playing and apply a reactive lock to block stale heartbeats
if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers.forEach(peer => {
if (peer && typeof peer === 'object') {
peer.playbackState = 'playing';
peer.lastReactiveUpdate = Date.now();
}
});
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
routeToContent(event, data);
break;
case EVENTS.EVENT_ACK:
if (lastActionState && lastActionState.action && data.senderId) {
if (lastActionState && lastActionState.action && data?.senderId) {
// Correlation Check: Only accept ACK if it matches our current action's timestamp
if (data.actionTimestamp === lastActionState.timestamp) {
if (!Array.isArray(lastActionState.acks)) lastActionState.acks = [];
@@ -530,6 +652,12 @@ function handleServerEvent(event, data) {
lastActionState.acks.push(data.senderId);
if (storageInitialized) chrome.storage.session.set({ lastActionState });
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
// ACK Reactive Update
updateLocalPeerState(data.senderId, {
playbackState: lastActionState.action === EVENTS.PLAY ? 'playing' : (lastActionState.action === EVENTS.PAUSE ? 'paused' : undefined),
currentTime: (lastActionState.action === EVENTS.SEEK || lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) ? lastActionState.targetTime : undefined
});
}
}
}
@@ -552,30 +680,34 @@ function handleServerEvent(event, data) {
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
// Episode Lobby: Handle peer departure
if (episodeLobby) {
checkEpisodeLobbyPeerDeparture();
}
if (isForceSyncInitiator) {
const peerCount = currentRoom.peers ? currentRoom.peers.length : 1;
const peerCount = Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
if (forceSyncAcks.size >= peerCount) {
executeForceSync();
}
}
} else {
// Heartbeat/Update: Update tabTitle for matching
const peer = currentRoom.peers.find(p => (p.peerId || p) === data.peerId);
const peer = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === data.peerId);
if (peer) {
if (typeof peer === 'object') {
peer.tabTitle = data.tabTitle;
peer.username = data.username;
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
peer.lastHeartbeat = Date.now();
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
const ignoreStatus = timeSinceReactive < 1000;
if (!ignoreStatus) {
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
peer.lastHeartbeat = Date.now();
}
} else {
// Migration: replace string peer with normalized object
const idx = currentRoom.peers.indexOf(peer);
@@ -647,8 +779,24 @@ function executeForceSync() {
forceSyncAcks: [],
forceSyncDeadline: null
});
emit(EVENTS.FORCE_SYNC_EXECUTE, {});
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, {});
// Set all peers to playing and apply a reactive lock to block stale heartbeats
if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers.forEach(peer => {
if (peer && typeof peer === 'object') {
peer.playbackState = 'playing';
peer.lastReactiveUpdate = Date.now();
}
});
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
const executionTimestamp = Date.now();
updateLastAction(EVENTS.FORCE_SYNC_EXECUTE, 'You', executionTimestamp);
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp });
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp });
addLog('Force Sync Executed', 'success');
}
@@ -699,10 +847,13 @@ function executeEpisodeLobby() {
clearEpisodeLobbyState();
addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success');
// Trigger a standard Force Sync at targetTime 0.0
isForceSyncInitiator = true;
forceSyncAcks.clear();
const deadline = Date.now() + 8500;
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
const timestamp = Date.now();
updateLastAction(EVENTS.FORCE_SYNC_PREPARE, 'You', timestamp);
lastActionState.targetTime = 0.0;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
chrome.storage.session.set({
isForceSyncInitiator: true,
forceSyncAcks: [],
@@ -710,15 +861,15 @@ function executeEpisodeLobby() {
});
const syncPayload = { targetTime: 0.0 };
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId });
routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload);
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp });
routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp });
forceSyncTimeout = setTimeout(() => {
if (isForceSyncInitiator) {
addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn');
executeForceSync();
}
}, 8500);
}, FORCE_SYNC_TIMEOUT);
}
function checkEpisodeLobbyCompletion() {
@@ -731,6 +882,7 @@ function checkEpisodeLobbyCompletion() {
function checkEpisodeLobbyPeerDeparture() {
if (!episodeLobby || !currentRoom) return;
if (!Array.isArray(currentRoom.peers)) return;
const remainingPeerIds = currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p);
// If only we remain, cancel the lobby
@@ -765,23 +917,23 @@ async function routeToContent(action, payload) {
const tabId = parseInt(currentTabId);
if (isNaN(tabId)) return;
currentCommandSenderId = payload.senderId || null;
const actionTimestamp = payload.actionTimestamp || Date.now();
const actionTimestamp = payload?.actionTimestamp || Date.now();
const commandSenderId = payload?.senderId || null;
chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
action,
payload,
actionTimestamp
actionTimestamp,
commandSenderId
}).catch(err => {
// Auto-Reinject if content script is missing or extension was reloaded
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).then(() => {
setTimeout(() => routeToContent(action, payload), 500);
}).catch(err => {
}).catch(_err => {
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
});
} else {
@@ -793,13 +945,15 @@ async function routeToContent(action, payload) {
}
// --- Keep-Alive Mechanism ---
chrome.alarms.create('keepAlive', { periodInMinutes: 1 });
chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
await ensureState();
if (alarm.name === 'keepAlive') {
chrome.storage.session.get('keepAlive', () => {});
if (!socket || socket.readyState !== WebSocket.OPEN) {
connect();
if (!reconnectFailed) {
connect();
}
} else if (currentRoom) {
// Heartbeat Logic: Always include identity metadata
const settings = await getSettings();
@@ -813,7 +967,30 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
}
});
function leaveOldRoomIfSwitching(newRoomId) {
if (currentRoom && currentRoom.roomId !== newRoomId) {
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
emit(EVENTS.LEAVE_ROOM, { peerId });
}
currentRoom = null;
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
// Reset force sync states
isForceSyncInitiator = false;
forceSyncAcks.clear();
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
chrome.storage.session.set({
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
});
// Cancel any active episode lobby
clearEpisodeLobbyState();
}
}
// --- Extension Message Listeners ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
@@ -827,9 +1004,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (message.type === 'CONNECT') {
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false });
const settings = await getSettings();
if (settings.roomId) {
leaveOldRoomIfSwitching(settings.roomId);
}
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
// Already connected, but maybe room changed or we need to refresh room state
const settings = await getSettings();
if (settings.roomId) {
emit(EVENTS.JOIN_ROOM, {
roomId: settings.roomId,
@@ -847,12 +1028,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'RETRY_CONNECT') {
reconnectFailed = false;
reconnectStartTime = null;
reconnectDelay = 1000;
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
chrome.storage.session.set({ reconnectFailed: false });
connect();
sendResponse({ status: 'ok' });
} else if (message.type === 'GET_STATUS') {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
if (reconnectFailed) status = 'reconnect_failed';
sendResponse({
status,
@@ -860,7 +1047,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peers: currentRoom ? currentRoom.peers : [],
lastActionState,
targetTabId: currentTabId,
episodeLobby: episodeLobby
episodeLobby: episodeLobby,
reconnectAttempts
});
} else if (message.type === 'LEAVE_ROOM') {
emit(EVENTS.LEAVE_ROOM, { peerId });
@@ -894,9 +1082,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'GET_HISTORY') {
sendResponse(history);
} else if (message.type === 'GET_ROOM_LIST') {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(`42${JSON.stringify([EVENTS.GET_ROOMS])}`);
}
emit(EVENTS.GET_ROOMS, {});
sendResponse({ status: 'ok' });
} else if (message.type === 'WEB_JOIN_REQUEST') {
const { roomId, password, useCustomServer, serverUrl } = message;
@@ -907,6 +1093,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
serverUrl: serverUrl || ''
}, async () => {
broadcastConnectionStatus('connecting');
leaveOldRoomIfSwitching(roomId);
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
// FORCE TRANSITION: Emit Join Room directly if already connected
const settings = await getSettings();
@@ -949,12 +1136,20 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const processEvent = () => {
const timestamp = Date.now();
updateLastAction(message.action, 'You', timestamp);
lastActionState.targetTime = message.payload?.targetTime !== undefined ? message.payload.targetTime : message.payload?.currentTime;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
message.payload.actionTimestamp = timestamp;
// Local Reactive Update
updateLocalPeerState(peerId, {
playbackState: message.action === EVENTS.PLAY ? 'playing' : (message.action === EVENTS.PAUSE ? 'paused' : undefined),
currentTime: message.payload.currentTime !== undefined ? message.payload.currentTime : (message.payload.targetTime !== undefined ? message.payload.targetTime : undefined)
});
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
isForceSyncInitiator = true;
forceSyncAcks.clear();
const deadline = Date.now() + 8500;
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
chrome.storage.session.set({
isForceSyncInitiator: true,
forceSyncAcks: [],
@@ -969,9 +1164,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
executeForceSync();
}
}, 8500);
}, FORCE_SYNC_TIMEOUT);
}
addToHistory(message.action, 'You');
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
const hasOtherPeers = currentRoom && Array.isArray(currentRoom.peers) && currentRoom.peers.length > 0;
if (isNonEssentialEvent && !hasOtherPeers) {
sendResponse({ status: 'ok_solo' });
return;
}
emit(message.action, { ...message.payload, peerId });
sendResponse({ status: 'ok' });
};
@@ -997,7 +1201,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
forceSyncAcks.add(peerId);
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
addLog(`Local ACK received (${forceSyncAcks.size})`, 'info');
const peerCount = currentRoom ? currentRoom.peers.length : 1;
// Local Force Sync ACK Reactive Update
if (lastActionState && lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) {
updateLocalPeerState(peerId, {
playbackState: 'paused',
currentTime: lastActionState.targetTime
});
}
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
if (forceSyncAcks.size >= peerCount) {
executeForceSync();
}
@@ -1006,11 +1219,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') {
// Content script successfully ran a command. Send ACK back to the initiator.
if (currentCommandSenderId && currentCommandSenderId !== peerId) {
const commandSenderId = message.commandSenderId;
if (commandSenderId && commandSenderId !== peerId) {
emit(EVENTS.EVENT_ACK, {
senderId: peerId,
targetId: currentCommandSenderId,
targetId: commandSenderId,
actionTimestamp: message.actionTimestamp
});
}
@@ -1033,21 +1246,25 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
emit(EVENTS.PEER_STATUS, statusPayload);
if (currentRoom && currentRoom.peers) {
if (currentRoom && Array.isArray(currentRoom.peers)) {
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
if (me && typeof me === 'object') {
me.tabTitle = currentTabTitle;
me.username = settings.username;
me.mediaTitle = message.payload.mediaTitle;
me.playbackState = message.payload.playbackState;
me.currentTime = message.payload.currentTime;
me.volume = message.payload.volume;
me.muted = message.payload.muted;
me.mediaTitle = message.payload?.mediaTitle;
me.playbackState = message.payload?.playbackState;
me.currentTime = message.payload?.currentTime;
me.volume = message.payload?.volume;
me.muted = message.payload?.muted;
me.lastHeartbeat = Date.now();
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
}
sendResponse({ status: 'ok' });
}).catch(err => {
addLog('Heartbeat settings error: ' + err.message, 'error');
sendResponse({ status: 'ok' });
});
} else if (message.type === 'SET_TARGET_TAB') {
currentTabId = message.tabId;
@@ -1086,7 +1303,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// Check setting
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
if (!epSettings.autoSyncNextEpisode) {
if (epSettings.autoSyncNextEpisode === false) {
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
sendResponse({ status: 'disabled' });
return;
@@ -1176,7 +1393,7 @@ chrome.tabs.onRemoved.addListener((tabId) => {
});
// Re-inject on full page refresh
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
chrome.tabs.onUpdated.addListener((tabId, changeInfo, _tab) => {
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
chrome.scripting.executeScript({
target: { tabId },
+127 -57
View File
@@ -9,7 +9,7 @@
if (window.koalaSyncInjected && chrome.runtime.id) {
return;
}
} catch (e) {
} catch (_e) {
// Context invalidated, proceed with re-injection
}
window.koalaSyncInjected = true;
@@ -42,15 +42,19 @@
// --- 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;
function expectEvent(state) {
expectedEvents.add(state);
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
if (expectedTimeouts[state]) {
clearTimeout(expectedTimeouts[state]);
delete expectedTimeouts[state];
}
const timeout = state === 'seek' ? 10000 : 1500;
expectedTimeouts[state] = setTimeout(() => {
expectedEvents.delete(state);
delete expectedTimeouts[state];
}, timeout);
}
@@ -59,9 +63,16 @@
}
// --- 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 ---
@@ -131,7 +142,7 @@
function startLobbyPoll(expectedTitle) {
stopLobbyPoll();
pendingLobbyTitle = expectedTitle;
_pendingLobbyTitle = expectedTitle;
// NOTE: Do NOT pause here. Three callers reach this function:
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
@@ -151,7 +162,7 @@
function stopLobbyPoll() {
pendingLobbyTitle = null;
_pendingLobbyTitle = null;
if (lobbyPollTimer) {
clearInterval(lobbyPollTimer);
lobbyPollTimer = null;
@@ -169,7 +180,7 @@
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
return;
}
data.targetTime = target;
data = { ...data, targetTime: target };
}
try {
@@ -223,7 +234,7 @@
expectEvent('seek');
video.currentTime = data.targetTime;
}
} catch (e) {
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
}
}
@@ -259,22 +270,26 @@
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;
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();
@@ -288,13 +303,23 @@
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();
}
}
@@ -385,8 +410,12 @@
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
expectedEvents.delete(eventState);
if (expectedTimeouts[eventState]) {
clearTimeout(expectedTimeouts[eventState]);
delete expectedTimeouts[eventState];
}
return;
}
chrome.runtime.sendMessage({
@@ -399,6 +428,9 @@
timestamp: Date.now()
}
});
// Trigger proactive heartbeat to push stabilized state
scheduleProactiveHeartbeat();
}
const handlePlay = () => reportEvent(EVENTS.PLAY);
@@ -415,7 +447,11 @@
// 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
if (expectedTimeouts['seek']) {
clearTimeout(expectedTimeouts['seek']);
delete expectedTimeouts['seek'];
}
lastReportedSeekTime = current;
// No log — this is routine programmatic behavior (Force Sync, lobby, peer command)
return;
}
@@ -446,7 +482,7 @@
};
let lastVideoSrc = null;
let lastVideoSrc = undefined;
// Episode detection handler for loadeddata event
const handleLoadedData = () => {
@@ -456,19 +492,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();
}
@@ -482,13 +521,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();
@@ -504,45 +549,70 @@
observerTimeout = setTimeout(checkVideo, 1000 - (now - lastMutate));
}
});
observer.observe(document.body, { childList: true, subtree: true });
observer.observe(document.documentElement, { childList: true, subtree: true });
// --- SHARED_HEARTBEAT_INJECT_START ---
const HEARTBEAT_INTERVAL_VAL = 15000;
// --- SHARED_HEARTBEAT_INJECT_END ---
// Heartbeat
// 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();
}
});
}
}, HEARTBEAT_INTERVAL_VAL);
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();
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.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+11 -7
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.3.1",
"version": "1.8.5",
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, and HTML5 sites in real-time with friends.",
"permissions": [
"storage",
@@ -17,21 +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"
}
},
"content_scripts": [
{
"matches": ["https://sync.koalastuff.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"
}
}
+190 -47
View File
@@ -199,16 +199,102 @@
}
.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>
<div id="toast-container"></div>
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
<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 -->
@@ -216,47 +302,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>
@@ -264,7 +355,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>
@@ -276,25 +367,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 -->
@@ -308,63 +410,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>
@@ -375,5 +504,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>
+689 -172
View File
File diff suppressed because it is too large Load Diff
+834 -2
View File
@@ -1,17 +1,230 @@
{
"name": "koalasync",
"version": "1.3.1",
"version": "1.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
"version": "1.3.1",
"version": "1.8.5",
"devDependencies": {
"archiver": "^7.0.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": "^10.2.4"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1",
"levn": "^0.4.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.22"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -41,6 +254,27 @@
"node": ">=14"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -54,6 +288,46 @@
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -396,6 +670,31 @@
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -410,6 +709,210 @@
"dev": true,
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.6.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.2.0",
"esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.4",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"estraverse": "^5.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"estraverse": "^5.2.0"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@@ -440,6 +943,13 @@
"bare-events": "^2.7.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -447,6 +957,71 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"flat-cache": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -501,6 +1076,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -529,6 +1117,26 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -536,6 +1144,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -546,6 +1164,19 @@
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -589,6 +1220,27 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
@@ -602,6 +1254,16 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -648,6 +1310,36 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -688,6 +1380,20 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -698,6 +1404,56 @@
"node": ">=0.10.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
"levn": "^0.4.1",
"prelude-ls": "^1.2.1",
"type-check": "^0.4.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -705,6 +1461,16 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -732,6 +1498,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -749,6 +1525,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
@@ -1005,6 +1791,19 @@
"b4a": "^1.6.4"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -1015,6 +1814,16 @@
"node": ">= 10.0.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -1038,6 +1847,16 @@
"node": ">= 8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
@@ -1136,6 +1955,19 @@
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zip-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
+5 -2
View File
@@ -1,13 +1,16 @@
{
"name": "koalasync",
"version": "1.3.1",
"version": "1.8.6",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
"build:extension": "node scripts/build-extension.js"
"build:extension": "node scripts/build-extension.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"devDependencies": {
"archiver": "^7.0.1",
"eslint": "^10.4.0",
"fs-extra": "^11.2.0"
}
}
+4 -1
View File
@@ -161,7 +161,10 @@ async function run() {
};
manifest.browser_specific_settings = {
gecko: {
id: "koalasync@koalastuff.net"
id: "koalasync@koalastuff.net",
data_collection_permissions: {
required: ["none"]
}
}
};
return manifest;
+3 -3
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
+193 -75
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://sync.koalastuff.net"],
origin: (origin, callback) => {
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
callback(null, true);
} else {
log('CORS', `Rejected origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
}
},
methods: ["GET", "POST"]
},
maxHttpBufferSize: 1024, // 1KB max per message
maxHttpBufferSize: 4096, // 4KB max per message (headroom for JOIN_ROOM payloads)
transports: ['websocket'],
allowUpgrades: false
});
@@ -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,17 +105,18 @@ 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 and event counts to prevent memory leak
setInterval(() => {
@@ -94,10 +127,15 @@ setInterval(() => {
}
}
for (const [socketId, entry] of eventCounts.entries()) {
if (now > entry.resetTime) {
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
eventCounts.delete(socketId);
}
}
for (const [ip, entry] of healthCounts.entries()) {
if (now > entry.resetTime) {
healthCounts.delete(ip);
}
}
}, 60000);
function checkConnectionRate(ip) {
@@ -118,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.
@@ -125,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;
@@ -146,7 +189,8 @@ 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);
}
@@ -164,7 +208,10 @@ function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
}
io.on('connection', (socket) => {
const clientIp = socket.handshake.address;
// Get real client IP behind proxy/CDN
const forwardedFor = socket.handshake.headers['x-forwarded-for'];
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : socket.handshake.address;
socket._clientIp = clientIp;
// 1. Connection Rate Limit
if (!checkConnectionRate(clientIp)) {
@@ -185,7 +232,14 @@ io.on('connection', (socket) => {
}
if (clientVersion) {
const [cMaj, cMin, cPatch] = clientVersion.split('.').map(Number);
if (typeof clientVersion !== 'string') {
log('AUTH', `Invalid version type from ${clientIp}`);
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
socket.disconnect(true);
return;
}
const parts = clientVersion.split('.').map(Number);
const cMaj = parts[0], cMin = parts[1], cPatch = parts[2] || 0;
const [mMaj, mMin, mPatch] = MIN_VERSION.split('.').map(Number);
if (isNaN(cMaj) || isNaN(cMin) || isNaN(cPatch)) {
log('AUTH', `Invalid version format (${clientVersion}) from ${clientIp}`);
@@ -225,7 +279,7 @@ io.on('connection', (socket) => {
try {
// Protocol check
if (protocolVersion !== '1.0.0') {
if (protocolVersion !== PROTOCOL_VERSION) {
log('AUTH', `Protocol mismatch from ${peerId}: ${protocolVersion}`);
socket.emit(EVENTS.ERROR, { message: 'Incompatible protocol version' });
return;
@@ -233,64 +287,97 @@ 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 (!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);
@@ -314,7 +401,9 @@ io.on('connection', (socket) => {
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" });
}
}
});
@@ -328,18 +417,19 @@ 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();
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
@@ -381,12 +471,20 @@ io.on('connection', (socket) => {
// Strip undefined keys for clean wire format
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
socket.to(mapping.roomId).emit(eventName, relayPayload);
}
}
} 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,
@@ -403,25 +501,30 @@ io.on('connection', (socket) => {
}
});
socket.on(EVENTS.EVENT_ACK, (data) => {
if (!data || typeof data !== 'object') return;
if (typeof data.targetId !== 'string') return;
if (data.actionTimestamp !== undefined && (typeof data.actionTimestamp !== 'number' || !Number.isFinite(data.actionTimestamp))) return;
const senderMapping = socketToRoom.get(socket.id);
const targetSocketId = peerToSocket.get(data.targetId);
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
socket.on(EVENTS.EVENT_ACK, (data) => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket (ACK): ${socket.id}`);
socket.disconnect(true);
return;
}
if (!data || typeof data !== 'object') return;
if (typeof data.targetId !== 'string') return;
if (data.actionTimestamp !== undefined && (typeof data.actionTimestamp !== 'number' || !Number.isFinite(data.actionTimestamp))) return;
const senderMapping = socketToRoom.get(socket.id);
const targetSocketId = peerToSocket.get(data.targetId);
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
// Security: Only relay ACK if both peers are in the same room
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
senderId: senderMapping.peerId,
actionTimestamp: data.actionTimestamp
});
} else {
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
}
});
// Security: Only relay ACK if both peers are in the same room
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
senderId: senderMapping.peerId,
actionTimestamp: data.actionTimestamp
});
} else {
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
@@ -441,7 +544,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 = [];
@@ -453,14 +560,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)`);
@@ -491,3 +599,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);
});
+81
View File
@@ -17,13 +17,37 @@ export const BLACKLIST_DOMAINS = [
'msn.com',
'baidu.com',
'yandex.ru',
'ecosia.org',
'startpage.com',
'search.brave.com',
'qwant.com',
'you.com',
'perplexity.ai',
'ask.com',
'search.yahoo.com',
'swisscows.ch',
'mojeek.com',
// Mail Providers
'mail.google.com',
'gmail.com',
'outlook.live.com',
'outlook.office.com',
'mail.yahoo.com',
'gmx.net',
'gmx.de',
'gmx.com',
'web.de',
'protonmail.com',
'proton.me',
't-online.de',
'posteo.de',
'mailbox.org',
'mail.de',
'zoho.com',
'fastmail.com',
'tutanota.com',
'mail.ru',
// Cloud Storage & Documents
'docs.google.com',
@@ -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,6 +109,15 @@ export const BLACKLIST_DOMAINS = [
'myanimelist.net',
// Development & Utilities
'koalastuff.net',
'auth.koalastuff.net',
'blog.koalastuff.net',
'clicker.koalastuff.net',
'cookies.koalastuff.net',
'multibox.koalastuff.net',
'snippets.koalastuff.net',
'status.koalastuff.net',
'sync.koalastuff.net',
'timer.koalastuff.net',
'localhost',
'zoom.us',
@@ -85,6 +125,47 @@ export const BLACKLIST_DOMAINS = [
'meet.google.com',
'chrome.google.com',
// Music Streaming
'music.youtube.com',
'open.spotify.com',
'soundcloud.com',
'deezer.com',
'tidal.com',
// Knowledge & Blogs
'wikipedia.org',
'medium.com',
'dev.to',
'news.ycombinator.com',
// Design & Creative Tools
'figma.com',
'canva.com',
'miro.com',
// Online IDEs & Hosting
'vscode.dev',
'replit.com',
'codesandbox.io',
'vercel.com',
'netlify.com',
// Social & Image Sharing
'pinterest.com',
'tumblr.com',
// Language Learning
'duolingo.com',
'hellotalk.com',
// Google Utilities
'calendar.google.com',
'keep.google.com',
// Finance & Payments
'paypal.com',
'stripe.com',
// Games & Idle Sites
'milkywayidle.com',
'melvoridle.com',
+1 -1
View File
@@ -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
+10 -1
View File
@@ -34,8 +34,17 @@ sync.koalastuff.net {
file_server
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
+207 -10
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>
`;
@@ -164,7 +195,7 @@ document.addEventListener('DOMContentLoaded', () => {
const isDE = document.documentElement.classList.contains('lang-de');
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
let count = 3;
let count = 2;
const updateCountdown = () => {
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>`
@@ -195,8 +226,8 @@ document.addEventListener('DOMContentLoaded', () => {
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 = '';
@@ -209,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

+4 -23
View File
@@ -7,26 +7,7 @@
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
<meta name="robots" content="noindex">
<script>
(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;
})();
function toggleLanguage(e) {
if(e) e.preventDefault();
var html = document.documentElement;
var currentIsEnglish = html.classList.contains('lang-en');
var newLang = currentIsEnglish ? 'de' : 'en';
html.classList.remove('lang-en', 'lang-de');
html.classList.add('lang-' + newLang);
html.lang = newLang;
localStorage.setItem('koala_lang', newLang);
}
</script>
<script src="lang-init.js"></script>
</head>
<body>
<div class="bg-blobs">
@@ -41,11 +22,11 @@
<img src="assets/logo.png" alt="KoalaSync Logo">
<span>KoalaSync</span>
</a>
<button class="hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Menu"></button>
<button class="hamburger" aria-label="Menu"></button>
<div class="nav-links">
<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" onclick="toggleLanguage(event)">🌍 EN/DE</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>
+6 -25
View File
@@ -7,26 +7,7 @@
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
<meta name="robots" content="noindex">
<script>
(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;
})();
function toggleLanguage(e) {
if(e) e.preventDefault();
var html = document.documentElement;
var currentIsEnglish = html.classList.contains('lang-en');
var newLang = currentIsEnglish ? 'de' : 'en';
html.classList.remove('lang-en', 'lang-de');
html.classList.add('lang-' + newLang);
html.lang = newLang;
localStorage.setItem('koala_lang', newLang);
}
</script>
<script src="lang-init.js"></script>
</head>
<body>
<div class="bg-blobs">
@@ -41,11 +22,11 @@
<img src="assets/logo.png" alt="KoalaSync Logo">
<span>KoalaSync</span>
</a>
<button class="hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Menu"></button>
<button class="hamburger" aria-label="Menu"></button>
<div class="nav-links">
<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" onclick="toggleLanguage(event)">🌍 EN/DE</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>
@@ -68,8 +49,8 @@
</h2>
<p lang="de">Administrator KoalaSync (Privatperson)</p>
<p lang="en">Administrator KoalaSync (Private Individual)</p>
<p lang="de">E-Mail: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_admin' + '@' + 'koalamail.rocks'">[E-Mail anzeigen]</span></p>
<p lang="en">E-Mail: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_admin' + '@' + 'koalamail.rocks'">[Show Email]</span></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;">
+368 -58
View File
@@ -4,7 +4,7 @@
<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.">
<meta name="description" content="Watch YouTube, Twitch, and HTML5 videos in sync with friends. KoalaSync is a privacy-first, open-source browser extension for Chrome and Firefox.">
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
@@ -13,30 +13,7 @@
<meta property="og:image" content="https://sync.koalastuff.net/assets/logo.png">
<meta property="og:type" content="website">
<script>
(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;
var titles = { en: 'KoalaSync | Real-time Video Synchronization for Friends', de: 'KoalaSync | Echtzeit-Video-Synchronisation für Freunde' };
document.title = titles[activeLang] || titles.en;
})();
function toggleLanguage(e) {
if(e) e.preventDefault();
var html = document.documentElement;
var currentIsEnglish = html.classList.contains('lang-en');
var newLang = currentIsEnglish ? 'de' : 'en';
html.classList.remove('lang-en', 'lang-de');
html.classList.add('lang-' + newLang);
html.lang = newLang;
localStorage.setItem('koala_lang', newLang);
var titles = { en: 'KoalaSync | Real-time Video Synchronization for Friends', de: 'KoalaSync | Echtzeit-Video-Synchronisation für Freunde' };
document.title = titles[newLang] || titles.en;
}
</script>
<script src="lang-init.js"></script>
</head>
<body>
@@ -52,12 +29,18 @@
<img src="assets/logo.png" alt="KoalaSync Logo">
<span>KoalaSync</span>
</div>
<button class="hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Menu"></button>
<button class="hamburger" aria-label="Menu"></button>
<div class="nav-links">
<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">GitHub</a>
<a class="lang-toggle" onclick="toggleLanguage(event)">🌍 EN/DE</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>
@@ -65,29 +48,197 @@
<header class="hero">
<div class="container hero-grid">
<div class="hero-text">
<div class="version-badge" data-reveal><span lang="en">v1.3.1 OUT NOW</span><span lang="de">v1.3.1 JETZT VERFÜGBAR</span></div>
<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">The ultimate free watch party extension for YouTube, Twitch, and local MP4s.</span>
<span lang="de">Die ultimative, kostenlose Watch-Party-Erweiterung für YouTube, Twitch und lokale MP4s.</span>
<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)">
<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="#" class="btn btn-primary" style="background: #e66000;">
<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" class="btn btn-secondary">
GitHub Repo
<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;" onclick="navigator.clipboard.writeText('https://sync.koalastuff.net/join.html#join:brave-eagle-80:pass')"><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>
</header>
@@ -97,33 +248,64 @@
<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 performance, privacy, and simplicity.</span><span lang="de">Entwickelt für Leistung, Privatsphäre und Einfachheit.</span>
<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><span lang="en">Real-time Sync</span><span lang="de">Echtzeit-Synchronisation</span></h3>
<p lang="en">Proprietary two-phase synchronization protocol ensures sub-millisecond precision across all peers.</p>
<p lang="de">Das proprietäre Zwei-Phasen-Synchronisationsprotokoll gewährleistet Sub-Millisekunden-Präzision für alle Teilnehmer.</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><span lang="en">Episode Auto-Sync</span><span lang="de">Automatische Episoden-Synchronisation</span></h3>
<p lang="en">New in v1.3.1: Perfectly sync series binges. All peers wait until everyone has loaded the next episode.</p>
<p lang="de">Neu in v1.3.1: Perfekt synchronisiertes Serien-Bingeing. Alle warten, bis jeder die nächste Episode geladen hat.</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><span lang="en">Privacy First</span><span lang="de">Privatsphäre zuerst</span></h3>
<p lang="en">Zero data persistence. Our relay server runs entirely in RAM and collects no telemetry or logs.</p>
<p lang="de">Keine Datenspeicherung. Unser Relay-Server läuft komplett im RAM und sammelt weder Telemetrie noch 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><span lang="en">Smart Match</span><span lang="de">Intelligente Zuordnung</span></h3>
<p lang="en">Find the right tab instantly. KoalaSync highlights and sorts matching video tabs for you.</p>
<p lang="de">Finden Sie sofort den richtigen Tab. KoalaSync markiert und sortiert passende Video-Tabs für Sie.</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>
@@ -141,10 +323,28 @@
<div class="step-text" data-reveal>
<div class="step-num">01</div>
<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 or download the latest developer ZIP from GitHub.</p>
<p lang="de">Fügen Sie KoalaSync aus dem Chrome Web Store zu Ihrem Browser hinzu oder laden Sie die neueste Entwickler-ZIP von GitHub herunter.</p>
<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>
@@ -153,16 +353,126 @@
<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>
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></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>
<div class="step">
<div class="step-text" data-reveal>
<div class="step-num">03</div>
<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 perfectly synced 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 perfekt synchronisierte Wiedergabe.</p>
<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;" onmouseover="this.style.opacity='1'" onmouseout="this.style.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>
@@ -172,7 +482,7 @@
<div class="container">
<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">Join thousands of users watching together.</span><span lang="de">Schließen Sie sich Tausenden von Nutzern an, die bereits zusammen schauen.</span>
<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>
+15 -31
View File
@@ -7,30 +7,7 @@
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/png" href="assets/logo.png">
<meta name="robots" content="noindex">
<script>
(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;
var titles = { en: 'Join Room | KoalaSync', de: 'Raum beitreten | KoalaSync' };
document.title = titles[activeLang] || titles.en;
})();
function toggleLanguage(e) {
if(e) e.preventDefault();
var html = document.documentElement;
var currentIsEnglish = html.classList.contains('lang-en');
var newLang = currentIsEnglish ? 'de' : 'en';
html.classList.remove('lang-en', 'lang-de');
html.classList.add('lang-' + newLang);
html.lang = newLang;
localStorage.setItem('koala_lang', newLang);
var titles = { en: 'Join Room | KoalaSync', de: 'Raum beitreten | KoalaSync' };
document.title = titles[newLang] || titles.en;
}
</script>
<script src="lang-init.js"></script>
</head>
<body>
<div class="bg-blobs">
@@ -45,11 +22,17 @@
<img src="assets/logo.png" alt="KoalaSync Logo">
<span>KoalaSync</span>
</a>
<button class="hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Menu"></button>
<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 class="lang-toggle" onclick="toggleLanguage(event)">🌍 EN/DE</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>
@@ -77,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;
}
})();
+788 -27
View File
@@ -156,43 +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, .hero-subtitle {
font-size: 1.5rem;
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;
@@ -200,6 +202,7 @@ nav {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
}
.btn-primary {
@@ -213,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);
@@ -228,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);
@@ -238,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;
}
@@ -256,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;
@@ -274,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;
}
@@ -304,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 --- */
@@ -331,6 +1091,7 @@ footer {
transform: translateY(0);
}
/* --- Legal Pages --- */
.legal-content {
max-width: 800px;
+4
View File
@@ -0,0 +1,4 @@
{
"version": "1.8.5",
"date": "2026-05-25T21:56:58Z"
}