Compare commits

..

71 Commits

Author SHA1 Message Date
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 3685 additions and 598 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
+20 -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,29 @@ 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.
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 |
+6 -5
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,7 +25,7 @@ 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.
---
+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:
+305 -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,7 +1164,7 @@ 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');
emit(message.action, { ...message.payload, peerId });
@@ -997,7 +1192,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 +1210,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 +1237,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 +1294,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 +1384,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.7.0",
"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"
}
}
+67 -3
View File
@@ -199,9 +199,48 @@
}
.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); }
}
</style>
</head>
<body>
<div id="toast-container"></div>
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
<div class="tabs">
@@ -289,12 +328,18 @@
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);">⏸ 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;">⚡ Force Sync</button>
<select id="forceSyncMode" style="width: auto; min-width: 130px; 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>
<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 -->
@@ -326,6 +371,11 @@
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
</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;">Browser Notifications</label>
<input type="checkbox" id="browserNotifications" style="width: auto;">
</div>
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
<p>• Username helps others identify you.</p>
@@ -375,5 +425,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.7); z-index:1000; align-items:center; justify-content:center;">
<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;">Skip</button>
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;">Next</button>
</div>
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
</div>
</div>
</body>
</html>
+487 -154
View File
@@ -8,6 +8,7 @@ const elements = {
copyInvite: document.getElementById('copyInvite'),
targetTab: document.getElementById('targetTab'),
forceSyncBtn: document.getElementById('forceSyncBtn'),
forceSyncMode: document.getElementById('forceSyncMode'),
peerList: document.getElementById('peerList'),
logList: document.getElementById('logList'),
clearLogs: document.getElementById('clearLogs'),
@@ -44,22 +45,41 @@ const elements = {
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
lobbyTitle: document.getElementById('lobbyTitle'),
lobbyPeerStatus: document.getElementById('lobbyPeerStatus')
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
browserNotifications: document.getElementById('browserNotifications')
};
let localPeerId = null;
let lastPeersJson = null;
let lastKnownPeers = [];
let isDevTabVisible = false;
let joinBtnTimeout = null;
let forceSyncResetTimer = null;
let popupIntervals = [];
let populateTabsToken = null;
let errorToken = 0;
let forceSyncDone = false;
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode']);
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications']);
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 });
}
elements.serverUrl.value = data.serverUrl || '';
elements.roomId.value = data.roomId || '';
elements.password.value = data.password || '';
elements.username.value = data.username || '';
elements.username.value = username;
elements.filterNoise.checked = data.filterNoise !== false;
elements.autoSyncNextEpisode.checked = !!data.autoSyncNextEpisode;
elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
elements.browserNotifications.checked = data.browserNotifications === true;
// Set Version Info
const versionEl = document.getElementById('appVersion');
@@ -80,10 +100,16 @@ async function init() {
// Initial Status Check
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
if (chrome.runtime.lastError) {
console.warn('[Popup] Background not responding:', chrome.runtime.lastError.message);
await populateTabs();
return;
}
if (res) {
localPeerId = res.peerId;
applyConnectionStatus(res.status);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
// Populate Tabs using the background's targetTabId
@@ -103,7 +129,12 @@ async function init() {
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
// Debug Info Refresh
setInterval(refreshDebugInfo, 2000);
popupIntervals.push(setInterval(refreshDebugInfo, 2000));
// Show onboarding on first visit
chrome.storage.sync.get(['onboardingComplete'], (data) => {
if (!data.onboardingComplete) showOnboarding();
});
}
// --- UI Logic ---
@@ -128,15 +159,25 @@ function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
}
} else {
updatePeerList([]);
if (elements.inviteLink) elements.inviteLink.value = '';
if (elements.activeRoomId) elements.activeRoomId.textContent = '';
if (elements.activeServer) {
elements.activeServer.textContent = '';
elements.activeServer.title = '';
}
lastKnownPeers = [];
}
}
function updateLastActionUI(state, peers) {
if (!state || !state.action) {
elements.lastActionCard.innerHTML = '<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding-top: 20px;">No recent commands</div>';
elements.lastActionCard.innerHTML = '<div style="text-align:center; color: var(--text-muted); font-size: 10px;">No recent commands</div>';
return;
}
const safePeers = peers || [];
const safeAcks = state.acks || [];
const actionNames = {
'play': 'PLAY',
'pause': 'PAUSE',
@@ -146,39 +187,51 @@ function updateLastActionUI(state, peers) {
};
let senderName = state.senderId === 'You' ? 'You' : state.senderId;
const senderPeer = peers.find(p => (p.peerId || p) === state.senderId);
const senderPeer = safePeers.find(p => (p.peerId || p) === state.senderId);
if (senderPeer && senderPeer.username) senderName = senderPeer.username;
const timeStr = new Date(state.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const ts = state.timestamp ? new Date(state.timestamp) : new Date();
const timeStr = isNaN(ts.getTime()) ? '--:--' : ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
// Clear previous content
elements.lastActionCard.innerHTML = '';
// Create Header
const header = document.createElement('div');
header.style.cssText = 'display:flex; justify-content:space-between; margin-bottom:10px; align-items:baseline;';
header.style.cssText = 'display:flex; justify-content:space-between; margin-bottom:6px; align-items:baseline;';
const actionSpan = document.createElement('span');
actionSpan.style.cssText = 'font-weight:700; color:var(--accent); font-size:13px;';
actionSpan.style.cssText = 'font-weight:700; color:var(--accent); font-size:11px;';
actionSpan.textContent = actionNames[state.action] || state.action.toUpperCase();
const infoSpan = document.createElement('span');
infoSpan.style.cssText = 'font-size:10px; color:var(--text-muted);';
infoSpan.style.cssText = 'font-size:9px; color:var(--text-muted);';
infoSpan.textContent = `${senderName} @ ${timeStr}`;
header.appendChild(actionSpan);
header.appendChild(infoSpan);
elements.lastActionCard.appendChild(header);
// Create Grid
const grid = document.createElement('div');
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 6px;';
if (state.targetTime !== undefined && state.action === 'seek') {
const timeInfo = document.createElement('div');
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
timeInfo.textContent = `Target: ${formatTime(state.targetTime)}`;
elements.lastActionCard.appendChild(timeInfo);
}
peers.forEach(peer => {
if (state.targetTime !== undefined && state.action.includes('force_sync')) {
const timeInfo = document.createElement('div');
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
timeInfo.textContent = `Sync to: ${formatTime(state.targetTime)}`;
elements.lastActionCard.appendChild(timeInfo);
}
const grid = document.createElement('div');
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 5px;';
safePeers.forEach(peer => {
const pId = typeof peer === 'object' ? peer.peerId : peer;
if (pId === localPeerId) return; // Exclude local user from acknowledgment list
if (pId === localPeerId) return;
const pName = (typeof peer === 'object' && peer.username) ? peer.username : pId.substring(0, 4);
const isAcked = state.acks.includes(pId) || pId === state.senderId;
const isAcked = safeAcks.includes(pId) || pId === state.senderId;
const color = isAcked ? 'var(--success)' : '#475569';
const icon = isAcked ? '✓' : '...';
@@ -187,11 +240,11 @@ function updateLastActionUI(state, peers) {
peerItem.style.cssText = `display:flex; flex-direction:column; align-items:center; opacity: ${isAcked ? 1 : 0.6};`;
const dot = document.createElement('div');
dot.style.cssText = `width:20px; height:20px; border-radius:50%; background:${color}; color:white; display:flex; align-items:center; justify-content:center; font-size:10px; font-weight:bold; margin-bottom:2px;`;
dot.style.cssText = `width:18px; height:18px; border-radius:50%; background:${color}; color:white; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:bold; margin-bottom:1px;`;
dot.textContent = icon;
const nameSpan = document.createElement('span');
nameSpan.style.cssText = 'font-size:8px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40px;';
nameSpan.style.cssText = 'font-size:7px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:36px;';
nameSpan.textContent = pName;
peerItem.appendChild(dot);
@@ -203,7 +256,7 @@ function updateLastActionUI(state, peers) {
}
function formatTime(seconds) {
if (seconds === null || seconds === undefined || isNaN(seconds)) return '--:--';
if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return '--:--';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
@@ -221,6 +274,13 @@ function getVolumeIcon(volume, muted) {
let activePeers = [];
let interpolationInterval = null;
function stopInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
interpolationInterval = null;
}
}
function startInterpolation() {
if (interpolationInterval) return;
interpolationInterval = setInterval(() => {
@@ -236,10 +296,31 @@ function startInterpolation() {
}, 1000);
}
function renderEmpty(container, type) {
const states = {
peers: { icon: '\u{1F465}', title: 'No peers yet', hint: 'Share your invite link to get started' },
history: { icon: '\u{1F4CB}', title: 'No activity yet', hint: 'Play, pause, or seek to see history' },
logs: { icon: '\u{1F4DD}', title: 'No logs', hint: 'Connection events will appear here' },
rooms: { icon: '\u{1F50D}', title: 'No active rooms', hint: 'Create a room or refresh to find public ones' }
};
const state = states[type] || { icon: '', title: '', hint: '' };
container.innerHTML = `
<div style="text-align:center; padding:16px 8px; color:var(--text-muted);">
<div style="font-size:24px; margin-bottom:6px;">${state.icon}</div>
<div style="font-size:12px; font-weight:600; margin-bottom:4px;">${state.title}</div>
<div style="font-size:10px; opacity:0.7;">${state.hint}</div>
</div>
`;
}
function updatePeerList(peers) {
if (!peers) return;
activePeers = peers;
if (!interpolationInterval) startInterpolation();
if (peers.length === 0) {
stopInterpolation();
} else if (!interpolationInterval) {
startInterpolation();
}
// UI Throttle: Only re-render if the peer state actually changed (excluding time interpolation)
const stateToHash = peers.map(p => ({
@@ -258,10 +339,7 @@ function updatePeerList(peers) {
const renderPeers = (container) => {
container.innerHTML = '';
if (peers.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 12px;';
empty.textContent = 'No peers connected';
container.appendChild(empty);
renderEmpty(container, 'peers');
return;
}
@@ -371,27 +449,68 @@ function updatePeerList(peers) {
populateTabs(peers);
}
function detectPeerChanges(newPeers) {
const oldIds = new Set(lastKnownPeers.map(p => p.peerId || p));
const newIds = new Set(newPeers.map(p => p.peerId || p));
for (const peer of newPeers) {
const id = peer.peerId || peer;
if (!oldIds.has(id)) {
const name = peer.username || id.substring(0, 4);
showToast(`${name} joined the room`, 'success');
}
}
for (const oldPeer of lastKnownPeers) {
const id = oldPeer.peerId || oldPeer;
if (!newIds.has(id)) {
const name = oldPeer.username || id.substring(0, 4);
showToast(`${name} left the room`, 'info');
}
}
lastKnownPeers = newPeers;
}
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const token = {};
populateTabsToken = token;
const data = await chrome.storage.sync.get(['filterNoise']);
const isFilterActive = data.filterNoise !== false;
// Fallback if not provided directly
let currentTargetTabId = providedTargetTabId;
if (currentTargetTabId === null) {
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
currentTargetTabId = status?.targetTabId;
if (chrome.runtime.lastError) {
if (populateTabsToken !== token) return;
currentTargetTabId = null;
} else {
currentTargetTabId = status?.targetTabId;
}
}
// Use provided peers or fetch if missing
let peerIds = providedPeers;
if (!peerIds) {
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
peerIds = status?.peers || [];
if (chrome.runtime.lastError) {
if (populateTabsToken !== token) return;
peerIds = [];
} else {
peerIds = status?.peers || [];
}
}
const tabs = await chrome.tabs.query({});
let tabs = [];
try {
tabs = await chrome.tabs.query({});
} catch (e) {
console.warn('[Popup] tabs.query failed:', e.message);
if (populateTabsToken !== token) return;
}
// Clear existing options except placeholder
if (!elements.targetTab) return;
if (populateTabsToken !== token) return;
while (elements.targetTab.options.length > 1) {
elements.targetTab.remove(1);
}
@@ -405,13 +524,17 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
return true;
});
// Smart Matching Logic — exclude own tabTitle to prevent self-match (computed once)
const peerTitles = peerIds
.filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId)
.map(p => (typeof p === 'object' ? p.tabTitle : null))
.filter(t => t && t.length > 3);
filteredTabs.forEach(tab => {
const option = document.createElement('option');
option.value = tab.id;
const title = (tab.title || 'Loading...');
// Smart Matching Logic
const peerTitles = peerIds.map(p => p.tabTitle).filter(t => t && t.length > 3);
const isMatch = peerTitles.some(pt => {
const t1 = title.toLowerCase();
const t2 = pt.toLowerCase();
@@ -429,10 +552,25 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
elements.targetTab.appendChild(option);
});
// Sort: Matches first
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
const options = Array.from(elements.targetTab.options);
const placeholder = options.shift(); // Remove placeholder
options.sort((a, b) => (b.textContent.includes('⭐') ? 1 : 0) - (a.textContent.includes('⭐') ? 1 : 0));
const placeholder = options.shift();
const currentTabId = providedTargetTabId ? parseInt(providedTargetTabId) : null;
options.sort((a, b) => {
const aId = parseInt(a.value);
const bId = parseInt(b.value);
if (aId === currentTabId) return -1;
if (bId === currentTabId) return 1;
const aMatch = a.textContent.includes('⭐');
const bMatch = b.textContent.includes('⭐');
if (aMatch && !bMatch) return -1;
if (!aMatch && bMatch) return 1;
return a.textContent.localeCompare(b.textContent);
});
elements.targetTab.innerHTML = '';
elements.targetTab.appendChild(placeholder);
options.forEach(opt => elements.targetTab.appendChild(opt));
@@ -445,37 +583,47 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
function applyConnectionStatus(status) {
const connected = status === 'connected';
const connecting = status === 'connecting';
const reconnecting = status === 'reconnecting';
const failed = status === 'reconnect_failed';
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : (connecting ? 'status-online' : 'status-offline')));
if (connecting) {
elements.connDot.style.background = '#fbbf24';
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
} else if (failed) {
elements.connDot.style.background = '#ef4444';
elements.connDot.style.boxShadow = 'none';
} else {
elements.connDot.style.background = '';
elements.connDot.style.boxShadow = '';
if (elements.connDot) {
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : ((connecting || reconnecting) ? 'status-online' : 'status-offline')));
if (reconnecting) {
elements.connDot.style.background = '#f59e0b';
elements.connDot.style.boxShadow = '0 0 8px #f59e0b';
} else if (connecting) {
elements.connDot.style.background = '#fbbf24';
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
} else if (failed) {
elements.connDot.style.background = '#ef4444';
elements.connDot.style.boxShadow = 'none';
} else {
elements.connDot.style.background = '';
elements.connDot.style.boxShadow = '';
}
}
elements.connText.textContent = connected ? 'Connected' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected'));
elements.retryBtn.style.display = failed ? 'block' : 'none';
// Update Join Button during auto-transition
if (connecting) {
elements.joinBtn.disabled = true;
elements.joinBtn.textContent = '🚀 Joining...';
} else {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = 'Join Room';
if (elements.connText) {
elements.connText.textContent = connected ? 'Connected' : (reconnecting ? 'Reconnecting...' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected')));
}
if (elements.retryBtn) {
elements.retryBtn.style.display = failed ? 'block' : 'none';
}
// Preserve icons for Remote Control buttons
elements.playBtn.textContent = '▶ Play';
elements.pauseBtn.textContent = '⏸ Pause';
elements.forceSyncBtn.textContent = '⚡ Force Sync Everyone';
if (elements.joinBtn) {
if (connecting || reconnecting) {
elements.joinBtn.disabled = true;
elements.joinBtn.textContent = connecting ? '🚀 Joining...' : '🔄 Reconnecting...';
} else {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = 'Join Room';
}
}
if (elements.playBtn) elements.playBtn.textContent = '▶ Play';
if (elements.pauseBtn) elements.pauseBtn.textContent = '⏸ Pause';
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = '⚡ Force Sync';
}
function updateHistory(history) {
@@ -483,10 +631,7 @@ function updateHistory(history) {
elements.historyList.innerHTML = '';
if (history.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'text-align:center; padding: 10px;';
empty.textContent = 'No activity yet';
elements.historyList.appendChild(empty);
renderEmpty(elements.historyList, 'history');
return;
}
@@ -534,10 +679,7 @@ function updateRoomList(rooms) {
elements.publicRooms.innerHTML = '';
if (!rooms || rooms.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'text-align:center; padding: 10px; color:var(--text-muted);';
empty.textContent = 'No active rooms';
elements.publicRooms.appendChild(empty);
renderEmpty(elements.publicRooms, 'rooms');
return;
}
@@ -584,39 +726,42 @@ function checkInviteLink() {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs[0];
if (tab && tab.url && tab.url.includes(OFFICIAL_LANDING_PAGE_URL) && tab.url.includes('#join:')) {
const rawHash = tab.url.split('#join:')[1];
const parts = rawHash.split(':');
if (parts.length >= 2) {
const roomId = parts.shift();
let useCustomServer = false;
let serverUrl = '';
try {
const rawHash = tab.url.split('#join:')[1];
if (!rawHash) return;
const parts = rawHash.split(':');
if (parts.length >= 2) {
const roomId = parts.shift();
let useCustomServer = false;
let serverUrl = '';
// Smart Link: Parse Server Config if present
const last = parts[parts.length - 1];
const secondToLast = parts[parts.length - 2];
const decodedLast = decodeURIComponent(last || '');
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
const isOfficial = secondToLast === '0' && last === '';
const last = parts[parts.length - 1];
const secondToLast = parts[parts.length - 2];
const decodedLast = decodeURIComponent(last || '');
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
const isOfficial = secondToLast === '0' && last === '';
if (parts.length >= 3 && (isCustom || isOfficial)) {
serverUrl = decodeURIComponent(parts.pop());
useCustomServer = parts.pop() === '1';
if (parts.length >= 3 && (isCustom || isOfficial)) {
serverUrl = decodeURIComponent(parts.pop());
useCustomServer = parts.pop() === '1';
}
const password = parts.join(':');
elements.roomId.value = roomId;
elements.password.value = password;
if (serverUrl || useCustomServer) {
elements.serverUrl.value = serverUrl;
setServerMode(useCustomServer);
chrome.storage.sync.set({ serverUrl, useCustomServer });
}
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
}
const password = parts.join(':');
elements.roomId.value = roomId;
elements.password.value = password;
if (serverUrl || useCustomServer) {
elements.serverUrl.value = serverUrl;
setServerMode(useCustomServer);
chrome.storage.sync.set({ serverUrl, useCustomServer });
}
// Visual feedback
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
} catch (_e) {
// Malformed invite link, ignore
}
}
});
@@ -626,7 +771,11 @@ function setServerMode(custom) {
elements.serverOfficial.classList.toggle('active', !custom);
elements.serverCustom.classList.toggle('active', custom);
elements.serverUrl.style.display = custom ? 'block' : 'none';
chrome.storage.sync.set({ useCustomServer: custom });
chrome.storage.sync.get(['useCustomServer'], (data) => {
if (data.useCustomServer !== custom) {
chrome.storage.sync.set({ useCustomServer: custom });
}
});
}
elements.serverOfficial.addEventListener('click', () => setServerMode(false));
@@ -642,6 +791,14 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
elements.browserNotifications.addEventListener('change', () => {
chrome.storage.sync.set({ browserNotifications: elements.browserNotifications.checked });
});
elements.forceSyncMode.addEventListener('change', () => {
chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value });
});
elements.serverUrl.addEventListener('input', () => {
chrome.storage.sync.set({ serverUrl: elements.serverUrl.value });
});
@@ -665,29 +822,34 @@ elements.tabs.forEach(btn => {
elements.contents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active');
isDevTabVisible = btn.dataset.tab === 'tab-dev';
if (isDevTabVisible) refreshLogs();
if (btn.dataset.tab === 'tab-sync') refreshHistory();
});
});
function showToast(message, type = 'info', duration = 3000) {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
function showError(msg) {
if (!elements.roomError) return;
const currentToken = ++errorToken;
elements.roomError.textContent = msg;
elements.roomError.style.display = 'block';
elements.roomId.style.borderColor = 'var(--error)';
elements.password.style.borderColor = 'var(--error)';
// Shake effect
const activeTab = document.querySelector('.tab-content.active');
if (activeTab) {
activeTab.animate([
{ transform: 'translateX(0)' },
{ transform: 'translateX(-5px)' },
{ transform: 'translateX(5px)' },
{ transform: 'translateX(0)' }
], { duration: 200, iterations: 2 });
}
showToast(msg, 'error', 5000);
setTimeout(() => {
if (currentToken !== errorToken) return;
if (elements.roomError) elements.roomError.style.display = 'none';
elements.roomId.style.borderColor = '';
elements.password.style.borderColor = '';
@@ -703,6 +865,14 @@ elements.joinBtn.addEventListener('click', async () => {
elements.joinBtn.disabled = true;
elements.joinBtn.textContent = isCreating ? 'Creating Room...' : 'Joining...';
if (joinBtnTimeout) clearTimeout(joinBtnTimeout);
joinBtnTimeout = setTimeout(() => {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = 'Join Room';
joinBtnTimeout = null;
showError('Connection timed out. Please try again.');
}, 15000);
const serverUrl = elements.serverUrl.value.trim();
const useCustom = elements.serverCustom.classList.contains('active');
@@ -711,7 +881,7 @@ elements.joinBtn.addEventListener('click', async () => {
try {
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'ws://' + serverUrl;
new URL(urlToCheck);
} catch (e) {
} catch (_e) {
showError('Invalid Server URL format.');
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = 'Join Room';
@@ -737,6 +907,7 @@ elements.leaveBtn.addEventListener('click', async () => {
await chrome.storage.sync.set({ roomId: '', password: '' });
elements.roomId.value = '';
elements.password.value = '';
lastKnownPeers = [];
updateUI(null, null);
});
@@ -744,7 +915,9 @@ elements.createRoomBtn.addEventListener('click', () => {
const animals = ['koala', 'panda', 'tiger', 'eagle', 'fox', 'bear'];
const adj = ['happy', 'cool', 'fast', 'smart', 'brave', 'calm'];
const id = `${adj[Math.floor(Math.random() * adj.length)]}-${animals[Math.floor(Math.random() * animals.length)]}-${Math.floor(Math.random() * 100)}`;
const pass = Math.random().toString(36).substring(2, 8);
const array = new Uint32Array(1);
window.crypto.getRandomValues(array);
const pass = array[0].toString(36).substring(0, 6);
elements.roomId.value = id;
elements.password.value = pass;
@@ -771,17 +944,42 @@ elements.forceSyncBtn.addEventListener('click', async () => {
if (elements.forceSyncBtn.disabled) return;
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
if (!status || !status.targetTabId) return;
if (chrome.runtime.lastError || !status || !status.targetTabId) return;
const mode = elements.forceSyncMode.value;
let targetTime = null;
if (mode === 'jump-to-others') {
if (!localPeerId) {
showError('Identity not yet loaded. Wait a moment and try again.');
return;
}
const peers = status.peers || [];
const otherTimes = peers
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && !isNaN(p.currentTime))
.map(p => p.currentTime);
if (otherTimes.length === 0) {
showError('No other peers with a known time. Switch to "Jump to Me".');
return;
}
otherTimes.sort((a, b) => a - b);
const mid = Math.floor(otherTimes.length / 2);
targetTime = otherTimes.length % 2 !== 0 ? otherTimes[mid] : (otherTimes[mid - 1] + otherTimes[mid]) / 2;
}
// Lockout to prevent spamming
const originalText = elements.forceSyncBtn.textContent;
elements.forceSyncBtn.disabled = true;
elements.forceSyncBtn.textContent = 'Syncing...';
setTimeout(() => {
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
}, 5000);
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? `Syncing to group (${formatTime(targetTime)})...` : 'Syncing...';
forceSyncDone = false;
const forceSyncReset = () => {
if (!forceSyncDone) {
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
}
};
forceSyncResetTimer = setTimeout(forceSyncReset, 12000);
const tabId = parseInt(status.targetTabId);
const sendForceSync = (time) => {
@@ -792,26 +990,34 @@ elements.forceSyncBtn.addEventListener('click', async () => {
});
};
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
if (chrome.runtime.lastError || !response || response.currentTime === undefined) {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).then(() => {
setTimeout(() => {
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
if (retryResponse && retryResponse.currentTime !== undefined) {
sendForceSync(retryResponse.currentTime);
}
});
}, 500);
}).catch(() => {
showError('Could not connect to video tab.');
});
return;
}
sendForceSync(response.currentTime);
});
if (mode === 'jump-to-me') {
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
if (chrome.runtime.lastError || !response || response.currentTime === undefined) {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).then(() => {
setTimeout(() => {
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
if (chrome.runtime.lastError) return;
if (retryResponse && retryResponse.currentTime !== undefined) {
sendForceSync(retryResponse.currentTime);
}
});
}, 500);
}).catch(() => {
showError('Could not connect to video tab.');
forceSyncDone = true;
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
});
return;
}
sendForceSync(response.currentTime);
});
} else {
sendForceSync(targetTime);
}
});
elements.playBtn.addEventListener('click', () => {
@@ -837,20 +1043,35 @@ elements.clearLogs.addEventListener('click', () => {
});
elements.copyInvite.addEventListener('click', () => {
navigator.clipboard.writeText(elements.inviteLink.value);
elements.copyInvite.textContent = '✅';
setTimeout(() => { elements.copyInvite.textContent = '📋'; }, 2000);
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
const original = elements.copyInvite.textContent;
elements.copyInvite.textContent = '';
elements.copyInvite.style.background = 'var(--success)';
elements.copyInvite.style.color = 'white';
showToast('Invite link copied!', 'success', 2000);
setTimeout(() => {
elements.copyInvite.textContent = original;
elements.copyInvite.style.background = '';
elements.copyInvite.style.color = '';
}, 2000);
}).catch(() => {
showToast('Failed to copy to clipboard', 'error');
});
});
// --- Logs & Status ---
async function refreshLogs() {
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
if (logs && elements.logList) {
if (elements.logList) {
if (!logs || logs.length === 0) {
renderEmpty(elements.logList, 'logs');
return;
}
elements.logList.innerHTML = '';
logs.forEach(log => {
const entry = document.createElement('div');
entry.className = `log-entry log-${log.type}`;
const timeStr = log.timestamp.split('T')[1].split('.')[0];
const timeStr = log.timestamp?.split('T')?.[1]?.split('.')[0] || '?';
entry.textContent = `[${timeStr}] ${log.message}`;
elements.logList.appendChild(entry);
});
@@ -861,12 +1082,39 @@ async function refreshLogs() {
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'LOG_UPDATE') {
refreshLogs();
if (msg.log && msg.log.type === 'error') {
showError(msg.log.message);
}
} else if (msg.type === 'ACTION_UPDATE') {
const state = msg.state;
if (state && state.senderId && state.senderId !== 'You') {
const actionNames = {
'play': '▶ Play',
'pause': '⏸ Pause',
'seek': '⏩ Seek',
'force_sync_prepare': '⚡ Force Sync',
'force_sync_execute': '⚡ Force Play'
};
const action = actionNames[state.action] || state.action;
showToast(`${state.senderId} ${action}`, 'info', 2000);
}
if (state && state.action === 'force_sync_execute') {
forceSyncDone = true;
if (forceSyncResetTimer) {
clearTimeout(forceSyncResetTimer);
forceSyncResetTimer = null;
}
if (elements.forceSyncBtn) {
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = '⚡ Force Sync';
}
}
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.peers) updateLastActionUI(msg.state, res.peers);
});
} else if (msg.type === 'PEER_UPDATE') {
updatePeerList(msg.peers);
if (msg.peers) detectPeerChanges(msg.peers);
} else if (msg.type === 'CONNECTION_STATUS') {
applyConnectionStatus(msg.status);
if (msg.status === 'connected') {
@@ -879,12 +1127,20 @@ chrome.runtime.onMessage.addListener((msg) => {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = 'Join Room';
}
if (msg.status === 'reconnecting') {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (chrome.runtime.lastError) return;
if (res && res.reconnectAttempts !== undefined) {
if (elements.connText) {
elements.connText.textContent = `Reconnecting... (${res.reconnectAttempts})`;
}
}
});
}
} else if (msg.type === 'HISTORY_UPDATE') {
updateHistory(msg.history);
} else if (msg.type === 'ROOM_LIST') {
updateRoomList(msg.rooms);
} else if (msg.type === 'LOG_UPDATE' && msg.log && msg.log.type === 'error') {
showError(msg.log.message);
} else if (msg.type === 'JOIN_STATUS') {
if (msg.success) {
// Final confirmation of join from background
@@ -915,6 +1171,8 @@ elements.copyLogs.addEventListener('click', () => {
const original = elements.copyLogs.textContent;
elements.copyLogs.textContent = 'Copied!';
setTimeout(() => elements.copyLogs.textContent = original, 2000);
}).catch(() => {
showToast('Failed to copy to clipboard', 'error');
});
});
});
@@ -966,7 +1224,7 @@ function refreshDebugInfo() {
};
addField('STATE', state.paused ? 'PAUSED' : 'PLAYING', 'var(--accent)');
addField('TIME', `${state.currentTime.toFixed(2)}s / ${state.duration.toFixed(2)}s`);
addField('TIME', `${state.currentTime.toFixed(2)}s / ${(state.duration || 0).toFixed(2)}s`);
addField('READY', state.readyState);
addSection('Identification');
@@ -997,7 +1255,23 @@ function refreshDebugInfo() {
}
init();
setInterval(refreshLogs, 5000);
popupIntervals.push(setInterval(() => {
if (isDevTabVisible) refreshLogs();
}, 5000));
window.addEventListener('unload', () => {
stopInterpolation();
popupIntervals.forEach(clearInterval);
popupIntervals = [];
if (joinBtnTimeout) {
clearTimeout(joinBtnTimeout);
joinBtnTimeout = null;
}
if (forceSyncResetTimer) {
clearTimeout(forceSyncResetTimer);
forceSyncResetTimer = null;
}
});
// --- Episode Lobby UI ---
function updateLobbyUI(lobby, peers) {
@@ -1026,15 +1300,74 @@ function updateLobbyUI(lobby, peers) {
});
}
if (peerLines.length > 0) {
if (peerLines.length > 0 && elements.lobbyPeerStatus) {
elements.lobbyPeerStatus.textContent = peerLines.join(' | ');
} else {
} else if (elements.lobbyPeerStatus) {
elements.lobbyPeerStatus.textContent = 'Waiting for peers...';
}
// Show elapsed time
if (lobby.createdAt) {
if (lobby.createdAt && elements.lobbyPeerStatus) {
const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000);
elements.lobbyPeerStatus.textContent += ` (${elapsed}s)`;
}
}
// --- Onboarding Tour ---
const onboardingSteps = [
{ icon: '\u{1F44B}', title: 'Welcome to KoalaSync!', text: 'Watch videos together in perfect sync — no matter where you are. Let\'s take a quick tour!' },
{ icon: '\u{1F3E0}', title: 'Room Tab', text: 'Create a room and share the invite link with friends. Anyone with the link can join instantly.' },
{ icon: '\u{1F3AC}', title: 'Sync Tab', text: 'Pick the tab with your video. Play, pause, and seek — everyone stays in sync. Drift? Just hit Force Sync.' },
{ icon: '\u2699\uFE0F', title: 'Settings', text: 'Pick a fun username, hide distracting tabs, and enable notifications so you never miss a moment.' },
{ icon: '\u{1F389}', title: 'You\'re all set!', text: 'Open a video, create a room, and start watching together. Enjoy!' }
];
let onboardingStep = 0;
function showOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
if (!overlay) return;
overlay.style.display = 'flex';
renderOnboardingStep();
}
function renderOnboardingStep() {
const step = onboardingSteps[onboardingStep];
const icon = document.getElementById('onboarding-icon');
const title = document.getElementById('onboarding-title');
const text = document.getElementById('onboarding-text');
const nextBtn = document.getElementById('onboarding-next');
const dots = document.getElementById('onboarding-dots');
if (!icon || !title || !text || !nextBtn || !dots) return;
icon.textContent = step.icon;
title.textContent = step.title;
text.textContent = step.text;
dots.innerHTML = onboardingSteps.map((_, i) =>
`<div style="width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};"></div>`
).join('');
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1 ? 'Done!' : 'Next';
}
function completeOnboarding() {
const overlay = document.getElementById('onboarding-overlay');
if (overlay) overlay.style.display = 'none';
chrome.storage.sync.set({ onboardingComplete: true });
}
document.getElementById('onboarding-next')?.addEventListener('click', () => {
onboardingStep++;
if (onboardingStep >= onboardingSteps.length) {
completeOnboarding();
} else {
renderOnboardingStep();
}
});
document.getElementById('onboarding-skip')?.addEventListener('click', completeOnboarding);
document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'onboarding-overlay') completeOnboarding();
});
+834 -2
View File
@@ -1,17 +1,230 @@
{
"name": "koalasync",
"version": "1.3.1",
"version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
"version": "1.3.1",
"version": "1.6.1",
"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.7.0",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
"build:extension": "node scripts/build-extension.js"
"build:extension": "node scripts/build-extension.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"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
+169 -74
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,44 @@ 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://')) {
callback(null, true);
} else {
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 +63,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();
@@ -73,17 +100,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 +122,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 +151,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 +167,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 +184,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 +203,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 +227,8 @@ io.on('connection', (socket) => {
}
if (clientVersion) {
const [cMaj, cMin, cPatch] = clientVersion.split('.').map(Number);
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 +268,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,15 +276,15 @@ 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)) {
socket.emit(EVENTS.ERROR, { message: "Too many failed attempts. Try again later." });
return;
@@ -250,21 +293,41 @@ io.on('connection', (socket) => {
let room = rooms.get(roomId);
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) {
// Another concurrent request created it, fall through to password check
}
}
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) {
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)}***`);
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);
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
} finally {
roomCreationLocks.delete(roomId);
resolveLock();
}
}
} else {
if (room.passwordHash) {
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
@@ -279,18 +342,26 @@ io.on('connection', (socket) => {
}
// 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 +385,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 +401,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,7 +455,10 @@ 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}`);
}
});
});
@@ -403,25 +480,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 +523,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 +539,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 +578,11 @@ 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.on('unhandledRejection', (reason) => {
log('ERROR', `Unhandled rejection: ${reason}`);
});
+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
+204 -7
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>
`;
@@ -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">A free, open-source watch party extension for YouTube, Twitch, and local MP4s. Built for reliable synchronization and data sovereignty.</span>
<span lang="de">Eine kostenlose, quelloffene Watch-Party-Erweiterung für YouTube, Twitch und lokale MP4s. Entwickelt für zuverlässige Synchronisation und Datenhoheit.</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">Target Tab (Video Source)</span><span lang="de">Ziel-Tab (Video-Quelle)</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">⚡ Force Sync Everyone</span><span lang="de">⚡ Synchronisation erzwingen</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">Filter Noise Tabs</span><span lang="de">Noise-Tabs filtern</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, and no database persistence. The server runs entirely in RAM, collects no telemetry or persistent logs, and purges your room when you leave.</p>
<p lang="de">Keine Registrierung, kein Tracking und keine Datenspeicherung. Der Server läuft flüchtig im RAM, speichert keine dauerhaften Logs und löscht deinen Raum 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">Take full ownership of your sync relay. We offer ready-to-use Docker images and lightweight Caddy configurations, enabling you to launch your private server in seconds.</p>
<p lang="de">Übernimm die volle Kontrolle. Mit unseren fertigen Docker-Images und einfachen Caddy-Konfigurationen startest du deinen eigenen, komplett privaten Relay-Server in wenigen Sekunden.</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.7.0",
"date": "2026-05-25T10:40:34Z"
}