mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ddfd1e21 | |||
| 4d85362020 | |||
| 53c1b8eea3 | |||
| 2b2aeeba00 | |||
| 5067b8e541 | |||
| 8778403449 | |||
| af87e34f5d | |||
| 385602c194 | |||
| bf0fa55b9d | |||
| f063fd5f3d | |||
| 0a555942f8 | |||
| d3f680e313 | |||
| e8203419a3 | |||
| e3536ad1a7 | |||
| aa1382b2ad | |||
| 44ee3fab25 | |||
| e9b55c72f0 | |||
| a83cdf4b03 | |||
| 3fb48ee822 | |||
| 2d20af7199 | |||
| 579677e11c | |||
| 05e1801653 | |||
| 6774b2bfb7 | |||
| 108d015e9f | |||
| e0b9e9ef27 | |||
| 641c9bfb24 | |||
| cf993b4ef0 | |||
| 5b1d6d7ba4 | |||
| 9ed7c98933 | |||
| 68b0f3306c | |||
| a71cdc3e53 | |||
| 366d157d80 | |||
| 2d231cb390 | |||
| b11b3316af | |||
| e337527e63 | |||
| 882565a079 |
+11
-4
@@ -94,14 +94,21 @@ 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.
|
||||
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.
|
||||
|
||||
### Adding a Protocol Event
|
||||
1. Add the event name to `shared/constants.js`.
|
||||
|
||||
+10
-1
@@ -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,4 +1,4 @@
|
||||
# KoalaSync
|
||||
# <img src="extension/icons/icon128.png" width="32" valign="middle"> KoalaSync
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
@@ -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** | 1 minute | `background.js` | Signals "I'm still connected" and handles 5-min auto-reconnect fallback |
|
||||
| **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").
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
+199
-20
@@ -7,7 +7,6 @@ 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 = [];
|
||||
@@ -105,6 +104,9 @@ 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 slowReconnectTimer = null; // Infinite slow background reconnect timer
|
||||
let isSlowReconnectAttempt = false; // True during slow background reconnect execution
|
||||
let lastSlowReconnectAttempt = 0;
|
||||
|
||||
// Force Sync Coordination
|
||||
let isForceSyncInitiator = false;
|
||||
@@ -137,6 +139,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 => (p.peerId || 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;
|
||||
@@ -148,12 +172,19 @@ async function getPeerId() {
|
||||
async function getSettings() {
|
||||
return new Promise(resolve => {
|
||||
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic'];
|
||||
const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark'];
|
||||
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: data.username || ''
|
||||
username: username
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -180,6 +211,9 @@ async function connect() {
|
||||
if (isConnecting) return;
|
||||
isConnecting = true;
|
||||
|
||||
const isCurrentSlowRetry = isSlowReconnectAttempt;
|
||||
isSlowReconnectAttempt = false;
|
||||
|
||||
let finalUrl = '';
|
||||
try {
|
||||
// --- Phase 1: Storage ---
|
||||
@@ -211,9 +245,9 @@ async function connect() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectFailed) {
|
||||
if (reconnectFailed && !isCurrentSlowRetry) {
|
||||
isConnecting = false;
|
||||
return;
|
||||
return; // Let keepAlive alarm handle the 5-min retry interval
|
||||
}
|
||||
|
||||
broadcastConnectionStatus('connecting');
|
||||
@@ -261,6 +295,12 @@ async function connect() {
|
||||
addLog('WebSocket Connection Opened', 'success');
|
||||
reconnectStartTime = null;
|
||||
reconnectFailed = false;
|
||||
isSlowReconnectAttempt = false;
|
||||
if (slowReconnectTimer) {
|
||||
clearTimeout(slowReconnectTimer);
|
||||
slowReconnectTimer = null;
|
||||
}
|
||||
chrome.storage.session.set({ reconnectFailed: false });
|
||||
isNamespaceJoined = false;
|
||||
socket.send('40');
|
||||
};
|
||||
@@ -300,7 +340,7 @@ async function connect() {
|
||||
try {
|
||||
const payload = JSON.parse(msg.substring(2));
|
||||
handleServerEvent(payload[0], payload[1]);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
addLog(`Failed to parse message: ${msg}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -319,6 +359,9 @@ async function connect() {
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
});
|
||||
|
||||
// Cancel any active episode lobby
|
||||
clearEpisodeLobbyState();
|
||||
|
||||
if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
@@ -371,6 +414,7 @@ function showNotification(senderName, action) {
|
||||
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;
|
||||
|
||||
// Find username in current room if available
|
||||
@@ -390,7 +434,13 @@ function showNotification(senderName, action) {
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || reconnectFailed) return;
|
||||
if (reconnectTimer) return;
|
||||
|
||||
isSlowReconnectAttempt = false;
|
||||
|
||||
if (reconnectFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reconnectStartTime) reconnectStartTime = Date.now();
|
||||
|
||||
@@ -398,7 +448,7 @@ function scheduleReconnect() {
|
||||
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. Entering slow background retry mode.', 'error');
|
||||
broadcastConnectionStatus('reconnect_failed');
|
||||
return;
|
||||
}
|
||||
@@ -410,6 +460,8 @@ function scheduleReconnect() {
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
// 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])}`;
|
||||
@@ -488,6 +540,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,6 +565,12 @@ 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
|
||||
@@ -518,7 +584,23 @@ function handleServerEvent(event, data) {
|
||||
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:
|
||||
@@ -530,6 +612,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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -571,11 +659,19 @@ function handleServerEvent(event, data) {
|
||||
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();
|
||||
|
||||
// Race condition guard: ignore heartbeat playbackState/currentTime
|
||||
// if we applied a reactive user action in the last 1.0 second.
|
||||
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 +743,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');
|
||||
}
|
||||
|
||||
@@ -781,7 +893,7 @@ async function routeToContent(action, payload) {
|
||||
files: ['content.js']
|
||||
}).then(() => {
|
||||
setTimeout(() => routeToContent(action, payload), 500);
|
||||
}).catch(err => {
|
||||
}).catch(_err => {
|
||||
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
|
||||
});
|
||||
} else {
|
||||
@@ -799,7 +911,16 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === 'keepAlive') {
|
||||
chrome.storage.session.get('keepAlive', () => {});
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
connect();
|
||||
if (reconnectFailed) {
|
||||
if (Date.now() - lastSlowReconnectAttempt >= 300000) {
|
||||
lastSlowReconnectAttempt = Date.now();
|
||||
isSlowReconnectAttempt = true;
|
||||
addLog('Alarm triggered 5-min slow reconnect attempt', 'info');
|
||||
connect();
|
||||
}
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
// Heartbeat Logic: Always include identity metadata
|
||||
const settings = await getSettings();
|
||||
@@ -813,7 +934,34 @@ 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) {
|
||||
try {
|
||||
socket.send(`42${JSON.stringify([EVENTS.LEAVE_ROOM, { peerId }])}`);
|
||||
} catch (_e) {
|
||||
addLog('Failed to send leave room packet during transition', 'error');
|
||||
}
|
||||
}
|
||||
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 +975,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (message.type === 'CONNECT') {
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
isSlowReconnectAttempt = false;
|
||||
if (slowReconnectTimer) {
|
||||
clearTimeout(slowReconnectTimer);
|
||||
slowReconnectTimer = null;
|
||||
}
|
||||
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,
|
||||
@@ -848,6 +1005,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectDelay = 1000;
|
||||
isSlowReconnectAttempt = false;
|
||||
if (slowReconnectTimer) {
|
||||
clearTimeout(slowReconnectTimer);
|
||||
slowReconnectTimer = null;
|
||||
}
|
||||
chrome.storage.session.set({ reconnectFailed: false });
|
||||
connect();
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'GET_STATUS') {
|
||||
@@ -894,9 +1057,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 +1068,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,8 +1111,16 @@ 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();
|
||||
@@ -997,6 +1167,15 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
forceSyncAcks.add(peerId);
|
||||
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
||||
addLog(`Local ACK received (${forceSyncAcks.size})`, 'info');
|
||||
|
||||
// Local Force Sync ACK Reactive Update
|
||||
if (lastActionState && lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
updateLocalPeerState(peerId, {
|
||||
playbackState: 'paused',
|
||||
currentTime: lastActionState.targetTime
|
||||
});
|
||||
}
|
||||
|
||||
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
executeForceSync();
|
||||
@@ -1086,7 +1265,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 +1355,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 },
|
||||
|
||||
+83
-34
@@ -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,7 +42,7 @@
|
||||
// --- 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) {
|
||||
@@ -131,7 +131,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 +151,7 @@
|
||||
|
||||
|
||||
function stopLobbyPoll() {
|
||||
pendingLobbyTitle = null;
|
||||
_pendingLobbyTitle = null;
|
||||
if (lobbyPollTimer) {
|
||||
clearInterval(lobbyPollTimer);
|
||||
lobbyPollTimer = null;
|
||||
@@ -223,7 +223,7 @@
|
||||
expectEvent('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -265,16 +265,20 @@
|
||||
|
||||
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 });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.PAUSE) {
|
||||
tryMediaAction(EVENTS.PAUSE);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
tryMediaAction(EVENTS.SEEK, payload);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
if (!payload || payload.targetTime === undefined) return;
|
||||
const video = findVideo();
|
||||
@@ -288,13 +292,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' });
|
||||
if (ready) {
|
||||
scheduleProactiveHeartbeat();
|
||||
} else {
|
||||
reportLog('Force Sync: Seek ready timeout, proceeding anyway', 'warn');
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
stopLobbyPoll(); // Clear any pending lobby on force sync
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
actionCompleted = true;
|
||||
}
|
||||
|
||||
if (actionCompleted) {
|
||||
scheduleProactiveHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,8 +399,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 +417,9 @@
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger proactive heartbeat to push stabilized state
|
||||
scheduleProactiveHeartbeat();
|
||||
}
|
||||
|
||||
const handlePlay = () => reportEvent(EVENTS.PLAY);
|
||||
@@ -415,7 +436,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;
|
||||
}
|
||||
@@ -504,42 +529,66 @@
|
||||
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) => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 889 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.3.1",
|
||||
"version": "1.6.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,12 +289,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 -->
|
||||
|
||||
+100
-45
@@ -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'),
|
||||
@@ -53,13 +54,22 @@ let lastPeersJson = null;
|
||||
// --- 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']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic'];
|
||||
const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark'];
|
||||
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';
|
||||
|
||||
// Set Version Info
|
||||
const versionEl = document.getElementById('appVersion');
|
||||
@@ -133,7 +143,7 @@ function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -151,32 +161,29 @@ function updateLastActionUI(state, peers) {
|
||||
|
||||
const timeStr = new Date(state.timestamp).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;';
|
||||
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 5px;';
|
||||
|
||||
peers.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 color = isAcked ? 'var(--success)' : '#475569';
|
||||
@@ -187,11 +194,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);
|
||||
@@ -221,6 +228,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(() => {
|
||||
@@ -239,7 +253,11 @@ function startInterpolation() {
|
||||
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 => ({
|
||||
@@ -405,13 +423,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();
|
||||
@@ -475,7 +497,7 @@ function applyConnectionStatus(status) {
|
||||
// Preserve icons for Remote Control buttons
|
||||
elements.playBtn.textContent = '▶ Play';
|
||||
elements.pauseBtn.textContent = '⏸ Pause';
|
||||
elements.forceSyncBtn.textContent = '⚡ Force Sync Everyone';
|
||||
elements.forceSyncBtn.textContent = '⚡ Force Sync';
|
||||
}
|
||||
|
||||
function updateHistory(history) {
|
||||
@@ -642,6 +664,10 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.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 });
|
||||
});
|
||||
@@ -711,7 +737,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';
|
||||
@@ -744,7 +770,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;
|
||||
@@ -773,10 +801,32 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||
if (!status || !status.targetTabId) return;
|
||||
|
||||
// Lockout to prevent spamming
|
||||
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;
|
||||
}
|
||||
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
elements.forceSyncBtn.disabled = true;
|
||||
elements.forceSyncBtn.textContent = 'Syncing...';
|
||||
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? `Syncing to group (${formatTime(targetTime)})...` : 'Syncing...';
|
||||
setTimeout(() => {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
@@ -792,26 +842,30 @@ 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 (retryResponse && retryResponse.currentTime !== undefined) {
|
||||
sendForceSync(retryResponse.currentTime);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
showError('Could not connect to video tab.');
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendForceSync(response.currentTime);
|
||||
});
|
||||
} else {
|
||||
sendForceSync(targetTime);
|
||||
}
|
||||
});
|
||||
|
||||
elements.playBtn.addEventListener('click', () => {
|
||||
@@ -850,7 +904,7 @@ async function refreshLogs() {
|
||||
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,6 +915,9 @@ 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') {
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.peers) updateLastActionUI(msg.state, res.peers);
|
||||
@@ -883,8 +940,6 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
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
|
||||
|
||||
Generated
+837
-2
@@ -1,17 +1,230 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.3.1",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "1.3.1",
|
||||
"version": "1.6.0",
|
||||
"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,47 @@
|
||||
"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",
|
||||
"peer": true,
|
||||
"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",
|
||||
@@ -153,6 +428,7 @@
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -396,6 +672,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 +711,211 @@
|
||||
"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",
|
||||
"peer": true,
|
||||
"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 +946,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 +960,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 +1079,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 +1120,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 +1147,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 +1167,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 +1223,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 +1257,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 +1313,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 +1383,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 +1407,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 +1464,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 +1501,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 +1528,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 +1794,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 +1817,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 +1850,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 +1958,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
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.3.1",
|
||||
"version": "1.6.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+21
-15
@@ -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();
|
||||
|
||||
@@ -125,12 +125,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;
|
||||
|
||||
@@ -225,7 +221,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;
|
||||
@@ -279,18 +275,23 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
|
||||
// Peer Deduplication: Remove existing socket for the same peerId
|
||||
// Snapshot stale SIDs first to avoid mutating the Map during iteration
|
||||
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) {
|
||||
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);
|
||||
@@ -404,6 +405,11 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+157
-7
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -255,6 +286,125 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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;">
|
||||
|
||||
+365
-55
@@ -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 perfectly synchronized 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" class="version-text-en">v1.3.1 OUT NOW</span><span lang="de" class="version-text-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">The ultimate free watch party extension for YouTube, Twitch, and local MP4s. Built for extreme precision and data sovereignty.</span>
|
||||
<span lang="de">Die ultimative, kostenlose Watch-Party-Erweiterung für YouTube, Twitch und lokale MP4s. Entwickelt für extreme Präzision 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 -> ACK</div>
|
||||
<div>[18:37:32] FORCE_SYNC -> 0.05s diff</div>
|
||||
<div>[18:37:38] EVENT: PAUSE -> 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 frame-perfect performance, absolute privacy, and total simplicity.</span>
|
||||
<span lang="de">Entwickelt für bildgenaue Leistung, absolute Privatsphäre und totale Einfachheit.</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 custom two-phase sync protocol coordinates playback with sub-millisecond precision across all participants.</p>
|
||||
<p lang="de">Einer pausiert, alle pausieren. Einer spult, alle folgen. Unser Zwei-Phasen-Synchronisationsprotokoll koordiniert die Wiedergabe in Echtzeit mit Sub-Millisekunden-Präzision.</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 perfectly 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 logs, and completely forgets your room when you leave.</p>
|
||||
<p lang="de">Keine Registrierung, kein Tracking und keine Datenspeicherung. Der Server läuft flüchtig im RAM, sammelt keine Logs und vergisst deinen Raum sofort 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, Disney+, Jellyfin, Emby, and any standard webpage containing a HTML5 video element. Perfect for custom self-hosted setups.</p>
|
||||
<p lang="de">Unterstützt YouTube, Twitch, Netflix, Disney+, 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 secure, 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 verschlüsselten 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,7 +353,20 @@
|
||||
<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>
|
||||
@@ -162,7 +375,104 @@
|
||||
<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>
|
||||
</div>
|
||||
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></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">Self-Hosters Paradise</span><span lang="de">Das Paradies 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 absolute data sovereignty. Deploy your own private high-performance relay server in under 60 seconds.</span>
|
||||
<span lang="de">Behalte die absolute Datenhoheit. Richte deinen eigenen privaten Hochleistungs-Relay-Server in unter 60 Sekunden 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+15
-31
@@ -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>© 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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "1.3.1",
|
||||
"date": "2026-05-18T13:02:13Z"
|
||||
"version": "1.6.0",
|
||||
"date": "2026-05-25T00:15:24Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user