Compare commits

..

7 Commits

Author SHA1 Message Date
Timo bd54e893b4 docs: Update root README for v1.2.0 features 2026-04-25 16:50:09 +02:00
Timo 50c9ba4ec8 docs: Update website for v1.2.0 release and add Episode Auto-Sync feature card 2026-04-25 16:49:52 +02:00
Timo 55c2d4ed0d feat: Auto-Sync Next Episode v1.2.0
Adds a new toggleable feature that detects episode transitions via
mediaTitle mutation (loadeddata/MutationObserver), pauses the video,
and waits for all room peers to load the same episode before
executing a coordinated Force Sync play at 0:00.

Protocol:
- Add EPISODE_LOBBY and EPISODE_READY events to shared/constants.js
- Add EPISODE_LOBBY_TIMEOUT (60s) constant
- Relay both new events in server/index.js

Content Script (content.js):
- Layered detection: loadeddata + MutationObserver src-change + heartbeat
- Debounced onEpisodeTransition() sends signal ONLY; no eager pause
- PAUSE_FOR_LOBBY handler pauses only after background confirms feature enabled
- startLobbyPoll() polls title match without premature pause for non-initiators
- checkAndReportLobbyReady() pauses and sends EPISODE_READY_LOCAL on match
- CONTENT_BOOT recovery for re-injection after hard navigation

Background (background.js):
- Episode lobby state persisted in chrome.storage.session with recovery
- EPISODE_CHANGED: checks setting, creates lobby, sends PAUSE_FOR_LOBBY to tab
- EPISODE_LOBBY/READY server event handlers with dedup logic
- 60s timeout cancels lobby (Option B) with Chrome failure notification
- Peer departure handled: removes from readyPeers, re-checks completion
- executeEpisodeLobby() reuses existing Force Sync pipeline at targetTime 0.0
- Lobby cleared on LEAVE_ROOM; status exposed in GET_STATUS

Popup:
- Auto-Sync Next Episode toggle in Settings tab (default: off, opt-in)
- Episode Lobby status card in Sync tab with peer readiness display
- LOBBY_UPDATE message handler for real-time UI updates

Bumps APP_VERSION and manifest to 1.2.0
2026-04-25 16:43:10 +02:00
Timo 02afc193c6 fix: extend input sanitization to relay path and remove whitespace artefact 2026-04-25 16:17:16 +02:00
Timo 7fc156977a feat: add graceful shutdown, input validation, and peer data factory (Phase 3) 2026-04-25 16:15:11 +02:00
Timo 99cb07bc2a refactor: centralize room cleanup logic to fix DRY violation (H-1) 2026-04-25 16:12:51 +02:00
Timo 77ffda3e42 fix: address phase 1 audit findings (xss, cors, dead code) 2026-04-25 16:10:20 +02:00
11 changed files with 692 additions and 143 deletions
+4
View File
@@ -2,6 +2,8 @@
KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchronized video playback across any website (YouTube, Twitch, Netflix, and custom HTML5 players).
**Latest Version**: `v1.2.0` (Episode Auto-Sync)
> [!TIP]
> **New Developers & AI Agents**: Please read [AI_INIT.md](AI_INIT.md) before starting work.
@@ -17,6 +19,7 @@ KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchr
## Key Features
- **Global Synchronization**: Synchronize Play, Pause, and Seeking on any website with a `<video>` tag.
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together (v1.2.0+).
- **Smart Matching**: Automatically highlights and sorts tabs containing matching video titles.
- **Noise Filtering**: Built-in domain blacklist to hide non-video sites from selection.
- **Smart Identity**: Customizable usernames combined with unique hexadecimal peer IDs.
@@ -25,6 +28,7 @@ KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchr
- **Integrated Diagnostics**: A dedicated "Dev" tab for real-time video state debugging.
- **Seamless Invitations**: Smart invitation links that automatically configure the server and room credentials for your friends.
## Setup Instructions
### 1. Relay Server (Docker)
+286 -61
View File
@@ -1,4 +1,4 @@
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION } from './shared/constants.js';
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT } from './shared/constants.js';
// --- State Management ---
let socket = null;
@@ -8,7 +8,6 @@ let isConnecting = false;
let peerId = null; // initialized via getPeerId()
let currentRoom = null;
let lastPeersJson = null;
let heartbeatInterval = null;
let currentTabId = null;
let currentTabTitle = null; // New: for Smart Matching
let logs = [];
@@ -30,7 +29,8 @@ function ensureState() {
chrome.storage.session.get([
'logs', 'history', 'currentRoom', 'lastActionState',
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle'
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
'episodeLobby'
], (data) => {
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
@@ -67,6 +67,17 @@ function ensureState() {
}
}
// Recover Episode Lobby
if (data.episodeLobby && !episodeLobby) {
episodeLobby = data.episodeLobby;
const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now();
if (lobbyRemaining > 0) {
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining);
} else {
cancelEpisodeLobby('Timeout (recovered)');
}
}
storageInitialized = true;
// Process any early logs/history that weren't captured in the spread
@@ -100,17 +111,30 @@ let isForceSyncInitiator = false;
let forceSyncAcks = new Set();
let forceSyncTimeout = null;
// --- Storage Utils ---
function startHeartbeat() {
// Session heartbeats are now handled by the chrome.alarms 'keepAlive' listener
// to ensure they survive Service Worker suspension in MV3.
}
// Episode Auto-Sync Lobby
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
let episodeLobbyTimeout = null;
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
// --- Storage Utils ---
/**
* Canonical peer data factory. All peer object construction must go through
* here to guarantee a consistent shape with predictable null defaults.
* @param {object} raw - Raw data from server event or heartbeat payload.
* @returns {object} Normalized peer data object.
*/
function createPeerData(raw) {
return {
peerId: raw.peerId || null,
username: raw.username || null,
tabTitle: raw.tabTitle || null,
mediaTitle: raw.mediaTitle || null,
playbackState: raw.playbackState || null,
currentTime: raw.currentTime != null ? raw.currentTime : null,
volume: raw.volume != null ? raw.volume : null,
muted: raw.muted != null ? raw.muted : null,
lastHeartbeat: Date.now()
};
}
async function getPeerId() {
@@ -418,17 +442,13 @@ function addToHistory(action, senderId) {
// --- Event Handlers ---
function handleServerEvent(event, data) {
switch (event) {
case EVENTS.ROOM_DATA:
currentRoom = data;
if (storageInitialized) chrome.storage.session.set({ currentRoom });
addLog(`Joined Room: ${data.roomId}`, 'success');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
// Start background heartbeat
startHeartbeat();
// Inform Website Bridge & Popup
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
chrome.runtime.sendMessage(joinStatusMsg).catch(() => {});
@@ -519,17 +539,7 @@ function handleServerEvent(event, data) {
if (!Array.isArray(currentRoom.peers)) currentRoom.peers = [];
if (data.status === 'joined') {
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
currentRoom.peers.push({
peerId: data.peerId,
username: data.username,
tabTitle: data.tabTitle,
mediaTitle: data.mediaTitle || null,
playbackState: data.playbackState || null,
currentTime: data.currentTime != null ? data.currentTime : null,
volume: data.volume != null ? data.volume : null,
muted: data.muted != null ? data.muted : null,
lastHeartbeat: Date.now()
});
currentRoom.peers.push(createPeerData(data));
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
@@ -537,6 +547,11 @@ function handleServerEvent(event, data) {
currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId);
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
// Episode Lobby: Handle peer departure
if (episodeLobby) {
checkEpisodeLobbyPeerDeparture();
}
} else {
// Heartbeat/Update: Update tabTitle for matching
const peer = currentRoom.peers.find(p => (p.peerId || p) === data.peerId);
@@ -551,19 +566,9 @@ function handleServerEvent(event, data) {
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
peer.lastHeartbeat = Date.now();
} else {
// Migration: replace string with object
// Migration: replace string peer with normalized object
const idx = currentRoom.peers.indexOf(peer);
currentRoom.peers[idx] = {
peerId: data.peerId,
username: data.username,
tabTitle: data.tabTitle,
mediaTitle: data.mediaTitle || null,
playbackState: data.playbackState || null,
currentTime: data.currentTime != null ? data.currentTime : null,
volume: data.volume != null ? data.volume : null,
muted: data.muted != null ? data.muted : null,
lastHeartbeat: Date.now()
};
currentRoom.peers[idx] = createPeerData(data);
}
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
@@ -571,6 +576,51 @@ function handleServerEvent(event, data) {
}
}
break;
case EVENTS.EPISODE_LOBBY:
if (data.senderId && data.expectedTitle) {
addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info');
// If we already have a lobby for this same title, treat as dedup
if (episodeLobby && episodeLobby.expectedTitle === data.expectedTitle) {
break; // Already tracking this lobby
}
// Cancel any existing lobby before starting a new one
if (episodeLobby) clearEpisodeLobbyState();
episodeLobby = {
expectedTitle: data.expectedTitle,
initiatorPeerId: data.senderId,
readyPeers: [],
createdAt: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
// Start timeout
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT);
// Forward to content script to start polling
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, {
type: 'EPISODE_LOBBY',
expectedTitle: data.expectedTitle
}).catch(() => {});
}
}
}
break;
case EVENTS.EPISODE_READY:
if (episodeLobby && data.senderId) {
if (!episodeLobby.readyPeers.includes(data.senderId)) {
episodeLobby.readyPeers.push(data.senderId);
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Episode ready from ${data.senderId} (${episodeLobby.readyPeers.length})`, 'info');
checkEpisodeLobbyCompletion();
}
}
break;
default:
addLog(`Received unknown event from server: ${event}`, 'warn');
break;
@@ -591,6 +641,102 @@ function executeForceSync() {
addLog('Force Sync Executed', 'success');
}
// --- Episode Auto-Sync Lobby Functions ---
function persistEpisodeLobby() {
if (storageInitialized) chrome.storage.session.set({ episodeLobby });
}
function broadcastLobbyUpdate() {
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobby }).catch(() => {});
}
function clearEpisodeLobbyState() {
if (episodeLobbyTimeout) clearTimeout(episodeLobbyTimeout);
episodeLobbyTimeout = null;
episodeLobby = null;
if (storageInitialized) chrome.storage.session.set({ episodeLobby: null });
broadcastLobbyUpdate();
// Notify content script to stop polling
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {});
}
}
}
function cancelEpisodeLobby(reason) {
if (!episodeLobby) return;
const title = episodeLobby.expectedTitle;
clearEpisodeLobbyState();
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
// Chrome notification on failure (per Q2: only notify on failure)
chrome.notifications.create(`episode_${Date.now()}`, {
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'KoalaSync — Episode Sync Failed',
message: `Auto-sync cancelled: ${reason}. You may need to manually sync.`,
priority: 1
});
}
function executeEpisodeLobby() {
if (!episodeLobby) return;
const title = episodeLobby.expectedTitle;
clearEpisodeLobbyState();
addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success');
// Trigger a standard Force Sync at targetTime 0.0
isForceSyncInitiator = true;
forceSyncAcks.clear();
const deadline = Date.now() + 5000;
chrome.storage.session.set({
isForceSyncInitiator: true,
forceSyncAcks: [],
forceSyncDeadline: deadline
});
const syncPayload = { targetTime: 0.0 };
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId });
routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload);
forceSyncTimeout = setTimeout(() => {
if (isForceSyncInitiator) {
addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn');
executeForceSync();
}
}, 5000);
}
function checkEpisodeLobbyCompletion() {
if (!episodeLobby || !currentRoom) return;
const peerCount = currentRoom.peers ? currentRoom.peers.length : 1;
if (episodeLobby.readyPeers.length >= peerCount) {
executeEpisodeLobby();
}
}
function checkEpisodeLobbyPeerDeparture() {
if (!episodeLobby || !currentRoom) return;
const remainingPeerIds = currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p);
// If only we remain, cancel the lobby
if (remainingPeerIds.length <= 1) {
cancelEpisodeLobby('All other peers left');
return;
}
// Filter readyPeers to only include peers still in the room
episodeLobby.readyPeers = episodeLobby.readyPeers.filter(id => remainingPeerIds.includes(id));
persistEpisodeLobby();
broadcastLobbyUpdate();
// Re-check if all remaining peers are now ready
checkEpisodeLobbyCompletion();
}
function updateLastAction(action, senderId, timestamp = Date.now()) {
lastActionState = {
action,
@@ -656,23 +802,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
}
});
setInterval(async () => {
await ensureState();
// Calling a chrome API keeps the SW alive in MV3 (Chrome 110+)
chrome.storage.session.get('keepAlive', () => {});
if (!socket || socket.readyState !== WebSocket.OPEN) {
connect();
} else if (currentRoom) {
// Redundant heartbeat for active SW state
const settings = await getSettings();
emit(EVENTS.PEER_STATUS, {
peerId,
status: 'heartbeat',
username: settings.username,
tabTitle: currentTabTitle
});
}
}, 30000); // every 30s
// --- Extension Message Listeners ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
@@ -718,24 +848,29 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId,
peers: currentRoom ? currentRoom.peers : [],
lastActionState,
targetTabId: currentTabId
targetTabId: currentTabId,
episodeLobby: episodeLobby
});
} else if (message.type === 'LEAVE_ROOM') {
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
currentTabId = null;
stopHeartbeat();
updateBadgeStatus();
isForceSyncInitiator = false;
forceSyncAcks.clear();
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
// Cancel any active episode lobby
clearEpisodeLobbyState();
chrome.storage.session.set({
currentRoom: null,
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
forceSyncDeadline: null,
episodeLobby: null
});
addLog('Left Room', 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -922,6 +1057,96 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'LOG') {
addLog(`[Content] ${message.message}`, message.level || 'info');
sendResponse({ status: 'ok' });
} else if (message.type === 'EPISODE_CHANGED') {
// Content script detected an episode transition
if (sender.tab) {
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
}
const newTitle = message.payload && message.payload.newTitle;
if (!newTitle) {
sendResponse({ status: 'no_title' });
return;
}
// Check setting
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
if (!epSettings.autoSyncNextEpisode) {
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
sendResponse({ status: 'disabled' });
return;
}
// If lobby already exists for this title, just mark self ready
if (episodeLobby && episodeLobby.expectedTitle === newTitle) {
if (!episodeLobby.readyPeers.includes(peerId)) {
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
emit(EVENTS.EPISODE_READY, { peerId, title: newTitle });
checkEpisodeLobbyCompletion();
}
sendResponse({ status: 'ready_sent' });
return;
}
// Cancel any existing lobby for a different episode
if (episodeLobby) clearEpisodeLobbyState();
// Create new lobby
episodeLobby = {
expectedTitle: newTitle,
initiatorPeerId: peerId,
readyPeers: [peerId], // We are already ready
createdAt: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Episode lobby created: "${newTitle}"`, 'info');
// Tell content script to pause the video and start polling
// (This is the only place we pause — after confirming the feature is enabled)
if (sender.tab && sender.tab.id) {
chrome.tabs.sendMessage(sender.tab.id, {
type: 'PAUSE_FOR_LOBBY',
expectedTitle: newTitle
}).catch(() => {});
}
// Broadcast to room
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: newTitle });
// Start timeout (Q1: Option B — cancel on timeout)
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), EPISODE_LOBBY_TIMEOUT);
// Immediate check — maybe we're the only one in the room
checkEpisodeLobbyCompletion();
sendResponse({ status: 'lobby_created' });
} else if (message.type === 'EPISODE_READY_LOCAL') {
// Content script confirmed it loaded the lobby episode
if (episodeLobby && message.payload && message.payload.title === episodeLobby.expectedTitle) {
if (!episodeLobby.readyPeers.includes(peerId)) {
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
emit(EVENTS.EPISODE_READY, { peerId, title: message.payload.title });
addLog(`Local episode ready: "${message.payload.title}"`, 'success');
checkEpisodeLobbyCompletion();
}
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CONTENT_BOOT') {
// Content script re-injected, check if there's an active lobby
if (episodeLobby) {
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
} else {
sendResponse({ lobbyActive: false });
}
} else {
// Final fallback to prevent channel hanging
sendResponse({ error: 'unhandled_message' });
+161 -1
View File
@@ -22,12 +22,20 @@
FORCE_SYNC_PREPARE: "force_sync_prepare",
FORCE_SYNC_ACK: "force_sync_ack",
FORCE_SYNC_EXECUTE: "force_sync_execute",
PEER_STATUS: "peer_status"
PEER_STATUS: "peer_status",
EPISODE_LOBBY: "episode_lobby",
EPISODE_READY: "episode_ready"
};
let expectedEvents = new Set();
let expectedTimeouts = {};
// --- Episode Auto-Sync State ---
let lastKnownMediaTitle = null;
let episodeTransitionDebounce = null;
let pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null;
function expectEvent(state) {
expectedEvents.add(state);
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
@@ -47,6 +55,100 @@
return videos.length > 0 ? videos[0] : null;
}
// --- Episode Auto-Sync: Detection ---
function getMediaTitle() {
return (navigator.mediaSession && navigator.mediaSession.metadata)
? navigator.mediaSession.metadata.title
: null;
}
function checkEpisodeTransition() {
const currentTitle = getMediaTitle();
const video = findVideo();
// Only trigger if: we had a previous title, the title changed,
// a video exists, and we're near the start of new content.
if (lastKnownMediaTitle && currentTitle
&& currentTitle !== lastKnownMediaTitle
&& video
&& video.currentTime < 5
&& video.readyState >= 1) {
onEpisodeTransition(currentTitle);
}
// Always track the latest known title
if (currentTitle) lastKnownMediaTitle = currentTitle;
}
function onEpisodeTransition(newTitle) {
// Debounce: prevent duplicate fires from multiple signals
if (episodeTransitionDebounce) return;
episodeTransitionDebounce = setTimeout(() => {
episodeTransitionDebounce = null;
}, 2000);
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
// Do NOT pause here. We notify background.js first.
// Background checks the setting; if enabled it creates a lobby
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
chrome.runtime.sendMessage({
type: 'EPISODE_CHANGED',
payload: { newTitle }
}).catch(() => {});
}
function checkAndReportLobbyReady(expectedTitle) {
const video = findVideo();
const currentTitle = getMediaTitle();
if (video && currentTitle && currentTitle === expectedTitle
&& video.currentTime < 5 && video.readyState >= 1) {
// Match! Pause at start and report ready.
if (!video.paused) {
expectEvent('paused');
video.pause();
}
stopLobbyPoll();
chrome.runtime.sendMessage({
type: 'EPISODE_READY_LOCAL',
payload: { title: currentTitle }
}).catch(() => {});
reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success');
return true;
}
return false;
}
function startLobbyPoll(expectedTitle) {
stopLobbyPoll();
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.
// 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing
// would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady()
// only once their title actually matches.
// 3. CONTENT_BOOT recovery: same reasoning as (2).
// Check immediately
if (checkAndReportLobbyReady(expectedTitle)) return;
// Poll every 2 seconds — no log spam, internal only
lobbyPollTimer = setInterval(() => {
checkAndReportLobbyReady(expectedTitle);
}, 2000);
}
function stopLobbyPoll() {
pendingLobbyTitle = null;
if (lobbyPollTimer) {
clearInterval(lobbyPollTimer);
lobbyPollTimer = null;
}
}
// --- Helper: YouTube/Twitch specific actions ---
function tryMediaAction(action, data) {
const video = findVideo();
@@ -181,11 +283,45 @@
});
}
} 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 });
}
}
// Episode Auto-Sync: Lobby notification from background
if (message.type === 'EPISODE_LOBBY') {
const expectedTitle = message.expectedTitle;
if (expectedTitle) {
reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info');
startLobbyPoll(expectedTitle);
}
sendResponse({ status: 'ok' });
return true;
}
// Episode Auto-Sync: Lobby cancelled by background
if (message.type === 'EPISODE_LOBBY_CANCEL') {
stopLobbyPoll();
sendResponse({ status: 'ok' });
return true;
}
// Episode Auto-Sync: Background confirmed lobby created, pause the video
if (message.type === 'PAUSE_FOR_LOBBY') {
const video = findVideo();
if (video && !video.paused) {
expectEvent('paused');
video.pause();
}
// Start lobby poll now that we know the feature is enabled
if (message.expectedTitle) {
startLobbyPoll(message.expectedTitle);
}
sendResponse({ status: 'ok' });
return true;
}
if (message.type === 'GET_VIDEO_STATE') {
const video = findVideo();
if (video) {
@@ -262,18 +398,30 @@
let lastVideoSrc = null;
// Episode detection handler for loadeddata event
const handleLoadedData = () => {
checkEpisodeTransition();
};
function setupListeners() {
const video = findVideo();
if (video) {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('seeked', handleSeeked);
video.removeEventListener('loadeddata', handleLoadedData);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('loadeddata', handleLoadedData);
video.dataset.koalaAttached = 'true';
lastVideoSrc = video.currentSrc || video.src;
// Initialize episode tracking title on first attach
if (!lastKnownMediaTitle) {
lastKnownMediaTitle = getMediaTitle();
}
}
}
@@ -289,6 +437,10 @@
const currentSrc = video.currentSrc || video.src;
if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) {
// If src changed, also check for episode transition
if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) {
checkEpisodeTransition();
}
setupListeners();
}
}
@@ -335,4 +487,12 @@
// Initial Setup
setupListeners();
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
if (res && res.lobbyActive && res.expectedTitle) {
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
startLobbyPoll(res.expectedTitle);
}
});
})();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.1.4",
"version": "1.2.0",
"description": "Synchronize video playback across different tabs and users.",
"permissions": [
"storage",
+16
View File
@@ -287,6 +287,16 @@
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding-top: 20px;">No recent commands</div>
</div>
<!-- Episode Auto-Sync Lobby Status -->
<div id="episodeLobbyCard" class="info-card" style="display:none; margin-bottom: 15px; border-left: 4px solid var(--star); animation: fadeIn 0.3s ease-out;">
<div style="display:flex; align-items:center; gap: 6px; margin-bottom: 6px;">
<span style="font-size: 16px;"></span>
<span style="font-weight: 700; color: var(--star); font-size: 12px;">EPISODE LOBBY</span>
</div>
<div id="lobbyTitle" style="font-size: 11px; color: var(--text); margin-bottom: 6px; font-weight: 600;"></div>
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted);"></div>
</div>
<div id="peerListSync" class="info-card" style="display:none;"></div>
</div>
@@ -301,10 +311,16 @@
<label style="margin-bottom: 0;">Filter Noise Tabs</label>
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
</div>
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
</div>
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
<p>• Username helps others identify you.</p>
<p>• Noise filtering uses a blacklist to hide common non-video sites (e.g. Search, Social Media) from the Target Tab selector.</p>
<p>• Auto-Sync will pause and wait for all peers when an episode changes, then sync-start together.</p>
</div>
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
+63 -2
View File
@@ -40,7 +40,11 @@ const elements = {
peerListSync: document.getElementById('peerListSync'),
videoDebug: document.getElementById('videoDebug'),
playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn')
pauseBtn: document.getElementById('pauseBtn'),
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
lobbyTitle: document.getElementById('lobbyTitle'),
lobbyPeerStatus: document.getElementById('lobbyPeerStatus')
};
let localPeerId = null;
@@ -49,12 +53,13 @@ let lastPeersJson = null;
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username']);
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode']);
elements.serverUrl.value = data.serverUrl || '';
elements.roomId.value = data.roomId || '';
elements.password.value = data.password || '';
elements.username.value = data.username || '';
elements.filterNoise.checked = data.filterNoise !== false;
elements.autoSyncNextEpisode.checked = !!data.autoSyncNextEpisode;
// Set Version Info
const versionEl = document.getElementById('appVersion');
@@ -83,6 +88,9 @@ async function init() {
// Populate Tabs using the background's targetTabId
await populateTabs(res.peers, res.targetTabId);
// Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
await populateTabs();
}
@@ -630,6 +638,10 @@ elements.filterNoise.addEventListener('change', () => {
});
});
elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
elements.serverUrl.addEventListener('input', () => {
chrome.storage.sync.set({ serverUrl: elements.serverUrl.value });
});
@@ -883,6 +895,15 @@ chrome.runtime.onMessage.addListener((msg) => {
// Join failed: reset UI state
updateUI(null, null);
}
} else if (msg.type === 'LOBBY_UPDATE') {
// Episode lobby state changed
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.peers) {
updateLobbyUI(msg.lobby, res.peers);
} else {
updateLobbyUI(msg.lobby, []);
}
});
}
});
@@ -977,3 +998,43 @@ function refreshDebugInfo() {
init();
setInterval(refreshLogs, 5000);
// --- Episode Lobby UI ---
function updateLobbyUI(lobby, peers) {
if (!elements.episodeLobbyCard) return;
if (!lobby) {
elements.episodeLobbyCard.style.display = 'none';
return;
}
elements.episodeLobbyCard.style.display = 'block';
elements.lobbyTitle.textContent = `\u{1F3AC} Waiting for: "${lobby.expectedTitle}"`;
// Build peer readiness list
const readySet = new Set(lobby.readyPeers || []);
const peerLines = [];
if (peers && peers.length > 0) {
peers.forEach(p => {
const pId = typeof p === 'object' ? p.peerId : p;
const pName = (typeof p === 'object' && p.username) ? p.username : pId;
const isReady = readySet.has(pId);
const icon = isReady ? '\u2705' : '\u23f3';
const label = isReady ? 'Ready' : 'Loading...';
peerLines.push(`${icon} ${pName} \u2014 ${label}`);
});
}
if (peerLines.length > 0) {
elements.lobbyPeerStatus.textContent = peerLines.join(' | ');
} else {
elements.lobbyPeerStatus.textContent = 'Waiting for peers...';
}
// Show elapsed time
if (lobby.createdAt) {
const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000);
elements.lobbyPeerStatus.textContent += ` (${elapsed}s)`;
}
}
+107 -69
View File
@@ -23,7 +23,7 @@ const httpServer = createServer(app);
// Socket.IO setup with security constraints
const io = new Server(httpServer, {
cors: {
origin: "*",
origin: ["https://koalasync.shik3i.net"],
methods: ["GET", "POST"]
},
maxHttpBufferSize: 1024, // 1KB max per message
@@ -113,6 +113,51 @@ function checkEventRate(socketId) {
return entry.count <= 30;
}
/**
* Central peer teardown. Removes a socket from all room state and notifies
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
*
* @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) {
const room = rooms.get(roomId);
if (!room) return;
const peerData = room.peerData.get(socketId);
if (!peerData) return; // Already cleaned up
const { peerId } = peerData;
// 1. Remove from room data structures
room.peers.delete(socketId);
room.peerIds.delete(socketId);
room.peerData.delete(socketId);
// 2. Remove from global maps
socketToRoom.delete(socketId);
if (peerToSocket.get(peerId) === socketId) {
peerToSocket.delete(peerId);
}
// 3. Notify remaining peers (use io.to so the removed socket itself
// doesn't receive it — it has already left or is disconnecting)
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
// 4. Delete empty room
if (room.peers.size === 0) {
rooms.delete(roomId);
log('ROOM', `Deleted empty room after ${reason}: ${roomId.substring(0, 3)}***`);
}
log('ROOM', `Peer ${peerId} removed (${reason}) from room ${roomId.substring(0, 3)}***`);
}
io.on('connection', (socket) => {
const clientIp = socket.handshake.address;
@@ -155,7 +200,16 @@ io.on('connection', (socket) => {
return;
}
if (!payload || typeof payload.roomId !== 'string') return;
const { roomId, password, peerId, username, tabTitle, mediaTitle, protocolVersion } = payload;
const { password, peerId, protocolVersion } = payload;
// --- M-2: Sanitize and clamp all string fields ---
const roomId = String(payload.roomId || '').substring(0, 64);
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
if (!roomId) return; // Guard: empty after sanitization
try {
// Protocol check
if (protocolVersion !== '1.0.0') {
@@ -171,14 +225,7 @@ io.on('connection', (socket) => {
}
if (oldMapping && oldMapping.roomId !== roomId) {
socket.leave(oldMapping.roomId);
const oldRoom = rooms.get(oldMapping.roomId);
if (oldRoom) {
oldRoom.peers.delete(socket.id);
oldRoom.peerIds.delete(socket.id);
oldRoom.peerData.delete(socket.id);
socket.to(oldMapping.roomId).emit(EVENTS.PEER_STATUS, { peerId: oldMapping.peerId, status: 'left' });
if (oldRoom.peers.size === 0) rooms.delete(oldMapping.roomId);
}
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
}
const ip = socket.handshake.address;
@@ -228,11 +275,7 @@ io.on('connection', (socket) => {
oldSocket.disconnect(true);
log('DEDUPE', `Kicked old session for peer ${peerId}`);
}
room.peers.delete(sid);
room.peerIds.delete(sid);
room.peerData.delete(sid);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
log('ROOM', `Deduplicated peer ${peerId} from room ${roomId}`);
removePeerFromRoom(sid, roomId, 'dedupe');
}
}
}
@@ -266,7 +309,8 @@ io.on('connection', (socket) => {
const relayEvents = [
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE,
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY
];
relayEvents.forEach(eventName => {
@@ -286,16 +330,19 @@ io.on('connection', (socket) => {
room.lastActivity = Date.now();
// Update peer metadata and lastSeen
// Sanitize mutable string fields to enforce the same length
// limits as JOIN_ROOM — the relay path is otherwise unbounded.
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : val;
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
room.peerData.set(socket.id, {
...existing,
username: data.username !== undefined ? data.username : existing.username,
tabTitle: data.tabTitle !== undefined ? data.tabTitle : existing.tabTitle,
mediaTitle: data.mediaTitle !== undefined ? data.mediaTitle : existing.mediaTitle,
playbackState: data.playbackState !== undefined ? data.playbackState : existing.playbackState,
currentTime: data.currentTime !== undefined ? data.currentTime : existing.currentTime,
volume: data.volume !== undefined ? data.volume : existing.volume,
muted: data.muted !== undefined ? data.muted : existing.muted,
username: data.username !== undefined ? clamp(data.username, 30) : existing.username,
tabTitle: data.tabTitle !== undefined ? clamp(data.tabTitle, 100) : existing.tabTitle,
mediaTitle: data.mediaTitle !== undefined ? clamp(data.mediaTitle, 100) : existing.mediaTitle,
playbackState: data.playbackState !== undefined ? data.playbackState : existing.playbackState,
currentTime: data.currentTime !== undefined ? data.currentTime : existing.currentTime,
volume: data.volume !== undefined ? data.volume : existing.volume,
muted: data.muted !== undefined ? data.muted : existing.muted,
lastSeen: Date.now()
});
@@ -317,23 +364,8 @@ io.on('connection', (socket) => {
socket.on(EVENTS.LEAVE_ROOM, () => {
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const { roomId, peerId } = mapping;
socket.leave(roomId);
const room = rooms.get(roomId);
if (room) {
room.peers.delete(socket.id);
room.peerIds.delete(socket.id);
room.peerData.delete(socket.id);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
if (room.peers.size === 0) {
rooms.delete(roomId);
log('ROOM', `Deleted empty room: ${roomId.substring(0, 3)}***`);
}
}
socketToRoom.delete(socket.id);
if (peerToSocket.get(peerId) === socket.id) {
peerToSocket.delete(peerId);
}
socket.leave(mapping.roomId);
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
}
});
@@ -359,22 +391,10 @@ io.on('connection', (socket) => {
eventCounts.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const { roomId, peerId } = mapping;
const room = rooms.get(roomId);
if (room) {
room.peers.delete(socket.id);
room.peerIds.delete(socket.id);
room.peerData.delete(socket.id);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
if (room.peers.size === 0) {
rooms.delete(roomId);
log('ROOM', `Deleted empty room (after disconnect): ${roomId.substring(0, 3)}***`);
}
}
socketToRoom.delete(socket.id);
if (peerToSocket.get(peerId) === socket.id) {
peerToSocket.delete(peerId);
}
// Socket is already disconnected — no need to call socket.leave().
// removePeerFromRoom uses io.to() for notifications, which correctly
// excludes this dead socket since it has already left all rooms.
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
}
});
});
@@ -387,23 +407,21 @@ setInterval(() => {
for (const [roomId, room] of rooms) {
// 1. Prune dead peers
// Snapshot keys first — we must not mutate peerData while iterating it.
const staleSids = [];
for (const [sid, data] of room.peerData.entries()) {
if (data.lastSeen && data.lastSeen < peerCutoff) {
const socket = io.sockets.sockets.get(sid);
if (socket) socket.leave(roomId);
room.peers.delete(sid);
room.peerIds.delete(sid);
room.peerData.delete(sid);
socketToRoom.delete(sid);
if (peerToSocket.get(data.peerId) === sid) {
peerToSocket.delete(data.peerId);
}
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
log('CLEANUP', `Pruned dead peer ${data.peerId} from room ${roomId}`);
staleSids.push(sid);
}
}
for (const sid of staleSids) {
// Gracefully evict the socket from the Socket.IO room if it is
// still technically connected (zombie with no heartbeat).
const deadSocket = io.sockets.sockets.get(sid);
if (deadSocket) deadSocket.leave(roomId);
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
removePeerFromRoom(sid, roomId, 'reaper');
}
// 2. Prune empty or inactive rooms
if (room.peers.size === 0 || room.lastActivity < roomCutoff) {
@@ -417,3 +435,23 @@ setInterval(() => {
httpServer.listen(PORT, () => {
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
});
// --- M-4: Graceful Shutdown ---
function gracefulShutdown(signal) {
log('SERVER', `${signal} received — starting graceful shutdown...`);
// 1. Notify all connected clients so they can display a meaningful message
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Please reconnect in a moment.' });
// 2. Stop accepting new HTTP connections
httpServer.close(() => {
log('SERVER', 'HTTP server closed. Exiting.');
process.exit(0);
});
// 3. Safety net: force-exit after 5s if connections don't drain
setTimeout(() => {
log('SERVER', 'Force-exit after timeout.');
process.exit(1);
}, 5000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
+7 -2
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.1.4";
export const APP_VERSION = "1.2.0";
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
@@ -32,8 +32,13 @@ export const EVENTS = {
FORCE_SYNC_EXECUTE: "force_sync_execute",
EVENT_ACK: "event_ack",
GET_ROOMS: "get_rooms",
ROOM_LIST: "room_list"
ROOM_LIST: "room_list",
// Episode Auto-Sync
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
EPISODE_READY: "episode_ready" // Response: loaded the episode and paused at 0:00
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
+25 -7
View File
@@ -104,12 +104,26 @@ document.addEventListener('DOMContentLoaded', () => {
const banner = document.createElement('div');
banner.className = 'invite-banner';
banner.id = 'koala-banner';
banner.innerHTML = `
<div class="container" style="display:flex; justify-content:space-between; align-items:center;">
<span>🎫 Invitation for <b>${roomId}</b> detected!</span>
<a href="join.html${window.location.hash}" class="btn-banner">OPEN JOIN PAGE</a>
</div>
`;
const container = document.createElement('div');
container.className = 'container';
container.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
const inviteSpan = document.createElement('span');
inviteSpan.appendChild(document.createTextNode('🎫 Invitation for '));
const boldRoom = document.createElement('b');
boldRoom.textContent = roomId;
inviteSpan.appendChild(boldRoom);
inviteSpan.appendChild(document.createTextNode(' detected!'));
const joinLink = document.createElement('a');
joinLink.href = 'join.html' + window.location.hash;
joinLink.className = 'btn-banner';
joinLink.textContent = 'OPEN JOIN PAGE';
container.appendChild(inviteSpan);
container.appendChild(joinLink);
banner.appendChild(container);
document.body.prepend(banner);
}
}
@@ -178,7 +192,11 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => window.close(), 3000);
} else {
banner.style.background = 'var(--error)';
banner.innerHTML = `<div class="container">❌ Error: ${message}</div>`;
banner.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.className = 'container';
errDiv.textContent = '❌ Error: ' + message;
banner.appendChild(errDiv);
}
}
}
+7
View File
@@ -38,6 +38,7 @@
<header class="hero">
<div class="container hero-grid">
<div class="hero-text">
<div class="version-badge" data-reveal>v1.2.0 OUT NOW</div>
<h1 data-reveal>Watch Together.<br>Sync Perfectly.</h1>
<p data-reveal>KoalaSync brings friends closer through synchronized video playback. No lag, no tracking, just shared moments.</p>
<div class="cta-group" data-reveal>
@@ -64,6 +65,11 @@
<h3>Real-time Sync</h3>
<p>Proprietary two-phase synchronization protocol ensures sub-millisecond precision across all peers.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🎬</div>
<h3>Episode Auto-Sync</h3>
<p>New in v1.2.0: Perfectly sync series binges. All peers wait until everyone has loaded the next episode.</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">🛡️</div>
<h3>Privacy First</h3>
@@ -75,6 +81,7 @@
<p>Find the right tab instantly. KoalaSync highlights and sorts matching video tabs for you.</p>
</div>
</div>
</div>
</section>
+15
View File
@@ -223,6 +223,21 @@ nav {
transform: translateY(-2px);
}
.version-badge {
display: inline-block;
background: rgba(99, 102, 241, 0.1);
color: var(--accent);
padding: 0.5rem 1.25rem;
border-radius: 99px;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 1.5rem;
border: 1px solid rgba(99, 102, 241, 0.3);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.hero-image {
display: none;
}