Compare commits

..

14 Commits

Author SHA1 Message Date
Timo 213d2867fc fix(extension): support Google Drive video frames 2026-07-26 03:52:05 +02:00
GitHub Action 90a58eb507 chore(release): update versions to v3.0.0 [skip ci] 2026-07-26 00:25:06 +00:00
KoalaDev 72113b6504 Merge pull request #32 from Shik3i/agent/update-tooling-node24
Prepare v3.0.0: encrypted text chat and runtime updates
2026-07-26 02:24:12 +02:00
Timo a0954079a1 harden chat settings and server image 2026-07-26 02:22:07 +02:00
Timo 9251ae6aff add dedicated chat settings 2026-07-26 02:12:28 +02:00
Timo 511e042b0b update tooling and server runtime 2026-07-26 01:58:23 +02:00
KoalaDev 372c384a19 Merge pull request #31 from Shik3i/agent/dependabot-security-updates
Fix Dependabot build dependency alerts
2026-07-26 01:32:41 +02:00
Timo 79715046a8 fix npm 11 lockfile portability 2026-07-26 01:23:07 +02:00
Timo 2ae79386a0 fix vulnerable build dependencies 2026-07-26 01:18:24 +02:00
Timo 883e49433e Merge branch 'feature/textchat' 2026-07-26 01:04:38 +02:00
Timo 59135b319e Merge remote-tracking branch 'origin/main' into feature/textchat
# Conflicts:
#	website/build.cjs
2026-07-26 01:03:27 +02:00
Timo ae2238e3bc docs: plan modern chat for v3.0 2026-07-26 01:02:33 +02:00
Timo 77790a279c fix(chat): preserve mixed-version compatibility 2026-07-26 01:02:20 +02:00
KoalaDev 2df048621b Centralize KoalaStuff legal notice 2026-07-23 03:04:20 +02:00
50 changed files with 2510 additions and 1495 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
### Prerequisites
- **Node.js** v18+
- **Node.js** v20.9+
- **Docker** (for local relay server testing)
### Quick Start
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.4-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.0.0-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
@@ -14,7 +14,7 @@
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.4 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v3.0.0 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
+16
View File
@@ -4,6 +4,22 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v3.0.0] — 2026-07-26
### Added
- **Extension: Optional encrypted room chat** — Rooms now support live-only, end-to-end encrypted text messages. Chat is disabled and hidden by default and can be enabled explicitly in the extension options.
- **Extension: Persistent room chat key** — Every room generates and retains a chat key even while chat is disabled, so chat can be enabled later without creating a new room.
- **Extension: Floating player chat** — When enabled, chat is available from a floating control over the selected player and opens in an overlay without replacing the synchronized video.
- **Extension: Dedicated chat settings** — Chat now has its own settings section for enablement, left/right/free-floating placement, compact/standard/large/custom sizing, and bubble-versus-open startup behavior.
### Security
- **Ciphertext-only relay** — The relay receives and forwards encrypted message payloads without storing chat history or accepting client-supplied plaintext, sender identities, timestamps, or message IDs as authoritative.
- **Backward-compatible rollout** — Versioned `chat-v1` capabilities ensure old non-chat extensions never receive unknown chat events, while current extensions continue to work with older chatless relay versions.
### Changed
- **Build dependencies** — Updated the supported build and validation toolchain and refreshed compatible transitive dependencies.
- **Relay container runtime** — Moved the production container from end-of-life Node.js 20 to Node.js 24 LTS and made production dependency installation deterministic with `npm ci --omit=dev`.
## [v2.6.4] — 2026-07-16
### Changed
+13
View File
@@ -67,6 +67,19 @@ the stamped value as AAD.
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations.
- Without a key, the panel stays closed and a disabled chat control explains that a
current invite link is required.
- Chat display is a local option and defaults to off. Enabling or disabling it never
deletes the room chat secret, so it can be enabled later without creating a room.
- Without the relay `chat` capability, no chat control is shown.
## Mixed-version rollout
- New extensions announce `chat-v1` in `join_room.clientCapabilities`.
- Old non-chat extensions omit the optional field and receive no `chat_message`
events. Their playback and room protocol remains unchanged.
- The first chat beta omitted the capability. When it sends one valid v1 chat
frame, the relay treats that socket as chat-capable for the rest of the
connection.
- New extensions accept the first beta's unversioned server `chat` capability as
well as `chat-v1`, so a server-first or extension-first rollout degrades safely.
Peer removal and role management are outside chat scope.
+15 -4
View File
@@ -54,6 +54,7 @@ Payload:
"password": "string, max 128, optional",
"tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional",
"clientCapabilities": ["chat-v1"],
"protocolVersion": "string, max 16"
}
```
@@ -82,7 +83,7 @@ Payload:
"hostPeerId": "string or null",
"controlMode": "everyone | host-only",
"controllers": ["peerId"],
"capabilities": ["host-control", "co-host", "chat"]
"capabilities": ["host-control", "co-host", "chat", "chat-v1"]
}
```
@@ -91,8 +92,17 @@ for every later room update.
## Ephemeral encrypted chat
Relays advertise chat support with `"chat"` in `room_data.capabilities`. Clients
must not infer support from another field.
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
the initial beta's `"chat"` flag during the transition. New clients announce
`"chat-v1"` in optional `join_room.clientCapabilities` (at most the first 16 entries
are inspected; unknown or malformed values are ignored). Old clients omit the field
and continue using the pre-chat protocol unchanged.
The relay sends `chat_message` only to sockets that announced `"chat-v1"`. As a
transition for the first chat beta, a socket that sends a valid v1 ciphertext is
marked capable for the rest of that connection. This prevents old non-chat
extensions from receiving unknown events while preserving the first beta's send
path.
### `chat_message`
@@ -106,7 +116,7 @@ Client to relay:
authentication tag. The relay validates only canonical base64url and byte bounds.
It cannot inspect plaintext.
Relay to every current room peer, including the sender:
Relay to every chat-capable current room peer, including the sender:
```json
{
@@ -413,5 +423,6 @@ If sender and target are still in the same room, the relay emits:
- `host-control`
- `co-host`
- `chat`
- `chat-v1`
Clients should treat a missing or unknown capabilities list as unsupported.
+77 -11
View File
@@ -30,6 +30,83 @@
*Prioritized for upcoming phases.*
### v3.0 — Modern Room Chat
- **Release date:** Not scheduled
- **Priority:** P1
- **Category:** Social / Communication / Privacy
- **Status:** Planned. This is a roadmap target, not a release announcement.
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
The relay stores no chat history and mixed extension versions use capability-gated
delivery.
#### Core experience
- Floating bubble with an accessible unread badge (`1`, `2`, `3`, …, `99+`), cleared
only when the chat is actually viewed.
- Subtle new-message pulse with reduced-motion support; no animation or sound while
muted or during Do Not Disturb.
- Drag-and-snap bubble positioning, remembered per site, with reset-to-default.
- Dock left/right, detached overlay, compact mode, fullscreen-safe placement, responsive
narrow-player layout, and keyboard-only operation.
- Per-room mute, mention-only mode, notification sound controls, browser notifications,
and a visible mute state.
- Unread state survives overlay close/reopen and tab focus changes, but room messages
remain memory-only unless a future storage option is explicitly enabled.
#### Messaging
- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing
indicators.
- Edit and delete own messages with clear local tombstones; no silent mutation.
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
duplicate suppression after reconnects.
- Message timestamps, date separators, jump-to-latest, unread divider, search within
the current live session, copy, and selectable text.
- Link detection with safe external opening, explicit scheme allowlist, and no automatic
previews or remote-media loading.
- Optional image/GIF/file sharing only after encrypted size limits, malware-risk UX,
relay bandwidth limits, and privacy behavior are designed and tested.
#### Presence, safety, and control
- Online/reconnecting status, join/leave events, and room-member presence without
exposing IP addresses or cross-room identity.
- Local mute/block, host moderation controls, slow mode, spam limits, and configurable
maximum message length.
- Optional read receipts and activity indicators default off; never infer or expose
viewing behavior without explicit consent.
- Clear live-only/no-history explanation, encrypted-chat indicator, room-key fingerprint,
and key-rotation/rejoin UX.
#### Reliability and compatibility
- Versioned chat capabilities for every new wire feature; unknown fields/events remain
harmless to older extensions and old non-chat clients receive no chat traffic.
- Server-first and extension-first rollout tests, including current, first-chat-beta,
old non-chat, reconnect, duplicate-frame, and malformed-capability clients.
- Bounded queues, payloads, unread counters, typing events, reactions, and attachment
metadata; backpressure and rate limits on both client and relay.
- Real Chrome and Firefox extension E2E coverage for bubble, unread count, overlay,
fullscreen, reconnect, and mixed-version rooms before v3.0 can ship.
#### Accessibility and localization
- Screen-reader announcements that do not read every busy-room message by default.
- Complete focus management, logical tab order, Escape behavior, high contrast,
zoom/reflow, reduced motion, and touch target coverage.
- All chat UI, notification text, moderation states, and errors localized across every
supported locale before release.
#### Explicit non-goals for the first v3.0 release
- No server-readable plaintext.
- No mandatory accounts, public room directory, advertising, tracking, or presence
across rooms.
- No unencrypted server-side history. Any future encrypted history requires a separate
threat model, retention controls, export/delete behavior, and explicit opt-in.
### 2. Invite link with target URL for auto-redirect
- **Priority:** P2
@@ -49,17 +126,6 @@
*Ideas and feature requests under evaluation.*
### In-room chat overlay (like TeleParty)
- **Priority:** P3
- **Category:** Social / Communication
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
- **Why backlog (still uncertain):**
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
- **Status:** Under evaluation, may come later.
### Cross-frame video detection and control
- **Priority:** P3
+287 -74
View File
@@ -7,7 +7,14 @@ import { initTabManager } from './modules/tab-manager.js';
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js';
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest, containsOriginPermission } from './host-access.js';
import {
GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED,
GOOGLE_DRIVE_PLAYER_AMBIGUOUS,
GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN,
isGoogleDriveUrl,
resolveMediaScriptTarget
} from './media-frame-target.js';
import './page-api-seek-overrides.js';
// --- Uninstall URL Initialization ---
@@ -65,6 +72,8 @@ let peerId = null; // initialized via getPeerId()
let currentRoom = null;
let currentTabId = null;
let currentTabTitle = null; // New: for Smart Matching
let currentTargetFrameId = 0;
let currentTargetDocumentId = null;
let targetActivationGeneration = 0;
let activeTargetActivation = null;
let logs = [];
@@ -91,6 +100,10 @@ let chatReceiveQueue = Promise.resolve();
const chatSendLimiter = createChatSendLimiter();
const webJoinCoordinator = createLatestTaskQueue();
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
function serverSupportsChat() {
return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT);
}
const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]);
function invalidateChatSession() {
chatSessionGeneration++;
@@ -189,12 +202,23 @@ function ensureState() {
'logs', 'history', 'currentRoom', 'lastActionState',
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
'currentTargetFrameId', 'currentTargetDocumentId',
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
'hcmDesynced'
], (data) => {
clearTimeout(storageTimeout);
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId);
currentTargetFrameId = currentTabId !== null
&& Number.isInteger(data.currentTargetFrameId)
&& data.currentTargetFrameId >= 0
? data.currentTargetFrameId
: 0;
currentTargetDocumentId = currentTabId !== null
&& typeof data.currentTargetDocumentId === 'string'
&& data.currentTargetDocumentId
? data.currentTargetDocumentId
: null;
if (data.currentTabTitle !== undefined) {
currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string'
? data.currentTabTitle
@@ -394,7 +418,7 @@ async function getSettings() {
// (username) must NEVER come from storage.sync — syncing them across devices
// both leaks them and resurrects dead rooms on reinstall (a fresh install
// has empty local storage but sync survives in the user's Google account).
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
let username = data.username;
if (!username) {
username = generateUsername();
@@ -412,6 +436,7 @@ async function getSettings() {
roomId,
password: data.password || '',
chatKey,
chatEnabled: data.chatEnabled === true,
username,
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
mediaTitlePrivacyMode
@@ -447,7 +472,8 @@ function emitEpisodeLobbyForCurrentPrivacy() {
// removes legacy keys that older versions wrote to sync (and that would
// otherwise be redistributed across devices and resurrected on reinstall).
const LEGACY_SYNC_KEYS = [
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username',
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
@@ -563,6 +589,77 @@ function isCurrentTargetIdentity(tabId, generation) {
&& targetActivationGeneration === generation;
}
function normalizeFrameId(value) {
return Number.isInteger(value) && value >= 0 ? value : 0;
}
function currentContentTarget() {
return {
frameId: normalizeFrameId(currentTargetFrameId),
documentId: typeof currentTargetDocumentId === 'string' && currentTargetDocumentId
? currentTargetDocumentId
: null
};
}
function sendMessageToFrame(tabId, frameId, message, callback = null, documentId = null) {
const options = typeof documentId === 'string' && documentId
? { documentId }
: { frameId: normalizeFrameId(frameId) };
if (typeof callback === 'function') {
return chrome.tabs.sendMessage(tabId, message, options, callback);
}
return chrome.tabs.sendMessage(tabId, message, options);
}
function sendMessageToCurrentContent(message, callback = null) {
const tabId = normalizeTabId(currentTabId);
if (tabId === null) {
return typeof callback === 'function' ? undefined : Promise.reject(new Error('No target tab selected'));
}
return sendMessageToFrame(
tabId,
currentTargetFrameId,
message,
callback,
currentTargetDocumentId
);
}
function sendMessageToContentTab(tabId, message, callback = null) {
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
return sendMessageToCurrentContent(message, callback);
}
if (typeof callback === 'function') {
return chrome.tabs.sendMessage(tabId, message, callback);
}
return chrome.tabs.sendMessage(tabId, message);
}
function isCurrentContentSender(sender) {
if (!sender?.tab) return false;
const senderTabId = normalizeTabId(sender.tab.id);
const senderFrameId = normalizeFrameId(sender.frameId);
const matchesActive = senderTabId === normalizeTabId(activeTargetActivation?.tabId)
&& senderFrameId === normalizeFrameId(activeTargetActivation?.frameId)
&& (!activeTargetActivation?.documentId
|| sender.documentId === activeTargetActivation.documentId);
if (Number.isInteger(activeTargetActivation?.frameId)) return matchesActive;
return senderTabId === normalizeTabId(currentTabId)
&& senderFrameId === normalizeFrameId(currentTargetFrameId)
&& (!currentTargetDocumentId || sender.documentId === currentTargetDocumentId);
}
function sameContentTarget(left, right) {
return normalizeFrameId(left?.frameId) === normalizeFrameId(right?.frameId)
&& (!left?.documentId || !right?.documentId || left.documentId === right.documentId);
}
function clearCurrentContentTarget() {
currentTargetFrameId = 0;
currentTargetDocumentId = null;
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
return false;
@@ -574,9 +671,10 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
clearPendingTarget().catch(() => {});
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) {
roomIdleSince = Date.now();
@@ -584,6 +682,8 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
chrome.storage.session.set({
currentTabId,
currentTabTitle,
currentTargetFrameId,
currentTargetDocumentId,
roomIdleSince,
lastContentHeartbeatAt
}).catch(() => {});
@@ -609,10 +709,11 @@ async function leaveRoomAfterIdleGrace(reason) {
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null;
lastContentHeartbeatAt = null;
clearEpisodeLobbyState();
@@ -621,6 +722,8 @@ async function leaveRoomAfterIdleGrace(reason) {
currentRoom: null,
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
roomIdleSince: null,
lastContentHeartbeatAt: null,
episodeLobby: null,
@@ -748,6 +851,7 @@ async function connect() {
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION
});
}
@@ -839,7 +943,7 @@ function broadcastControlMode() {
chrome.runtime.sendMessage(payload).catch(() => {});
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) chrome.tabs.sendMessage(tabId, payload).catch(() => {});
if (!isNaN(tabId)) sendMessageToContentTab(tabId, payload).catch(() => {});
}
}
@@ -851,7 +955,7 @@ function broadcastConnectionStatus(status) {
status = 'idle';
}
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CONNECTION_STATUS', status }).catch(() => {});
updateBadgeStatus();
}
@@ -1114,7 +1218,7 @@ async function handleServerEvent(event, data) {
hostPeerId = data.hostPeerId || null;
controllers = Array.isArray(data.controllers) ? data.controllers : [];
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
hcmEnforceDesyncInvariant();
broadcastControlMode();
markRoomPotentiallyIdle();
@@ -1149,7 +1253,7 @@ async function handleServerEvent(event, data) {
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, {
sendMessageToContentTab(tabId, {
type: 'EPISODE_LOBBY',
expectedTitle: episodeLobby.expectedTitle
}).catch(() => {});
@@ -1193,7 +1297,7 @@ async function handleServerEvent(event, data) {
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
break;
case EVENTS.CHAT_MESSAGE: {
if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break;
if (!currentRoom || !serverSupportsChat() || !currentTabId) break;
const generation = chatSessionGeneration;
const roomId = currentRoom.roomId;
const tabId = Number(currentTabId);
@@ -1203,7 +1307,7 @@ async function handleServerEvent(event, data) {
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
if (!isCurrentSession()) return;
const settings = await getSettings();
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
if (!settings.chatEnabled || !settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
const chatKey = settings.chatKey;
try {
const text = await decryptChatMessage({
@@ -1217,7 +1321,7 @@ async function handleServerEvent(event, data) {
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
);
if (Number.isInteger(tabId)) {
chrome.tabs.sendMessage(tabId, {
sendMessageToContentTab(tabId, {
type: 'CHAT_MESSAGE',
message: {
id: received.id,
@@ -1401,7 +1505,7 @@ async function handleServerEvent(event, data) {
// current playback state so the newcomer syncs immediately
// instead of waiting up to a full heartbeat interval.
if (wasSolo && currentTabId) {
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {});
sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {});
}
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
@@ -1491,7 +1595,7 @@ async function handleServerEvent(event, data) {
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, {
sendMessageToContentTab(tabId, {
type: 'EPISODE_LOBBY',
expectedTitle: data.expectedTitle
}).catch(() => {});
@@ -1603,7 +1707,7 @@ function clearEpisodeLobbyState() {
if (currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {});
sendMessageToContentTab(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {});
}
}
}
@@ -1732,7 +1836,19 @@ async function routeToContent(action, payload) {
const tabId = normalizeTabId(currentTabId);
if (tabId === null) return;
const targetGeneration = targetActivationGeneration;
let targetGeneration = targetActivationGeneration;
try {
const tab = await chrome.tabs.get(tabId);
if (isGoogleDriveUrl(tab?.url || '')) {
const activation = await reactivateCurrentTarget(tabId, { expectedGeneration: targetGeneration });
if (activation?.status !== 'ok') return;
targetGeneration = activation.generation;
}
} catch (error) {
addLog(`Google Drive target refresh failed: ${error.message}`, 'warn');
return;
}
const actionTimestamp = payload?.actionTimestamp || Date.now();
const commandSenderId = payload?.senderId || null;
@@ -1742,7 +1858,7 @@ async function routeToContent(action, payload) {
function getTabVideoState(tabId) {
return new Promise((resolve) => {
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
sendMessageToContentTab(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
if (chrome.runtime.lastError) {
resolve({ error: chrome.runtime.lastError.message });
return;
@@ -1754,7 +1870,7 @@ function getTabVideoState(tabId) {
async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) {
let state = await getTabVideoState(tabId);
if (!state || state.error) {
if (!state || state.error || state.found === false) {
const activation = await reactivateCurrentTarget(tabId, { expectedGeneration });
if (activation?.status !== 'ok') {
return { error: 'Target tab changed before content script recovery completed' };
@@ -1918,11 +2034,33 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
let needsPageApiSeek = false;
let pageApiSeekReady = false;
let access = null;
let scriptTarget = { tabId };
try {
access = await inspectTabHostAccess(chrome, tabId);
const url = access.url || '';
needsPageApiSeek = shouldUsePageApiSeek(url);
} catch (_e) {
scriptTarget = await resolveMediaScriptTarget(chrome, tabId, url);
if (activeTargetActivation?.tabId === tabId) {
activeTargetActivation.frameId = Array.isArray(scriptTarget.frameIds)
? normalizeFrameId(scriptTarget.frameIds[0])
: 0;
activeTargetActivation.documentId = null;
}
} catch (error) {
if (error?.code === GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED) {
const requestAdded = requestHostAccess
? await addTabHostAccessRequest(chrome, tabId, GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN)
: false;
const accessError = new Error('KoalaSync needs access to the embedded Google Drive player');
accessError.code = HOST_ACCESS_REQUIRED_STATUS;
accessError.tabId = tabId;
accessError.host = 'youtube.googleapis.com';
accessError.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN;
accessError.requestAdded = requestAdded === true;
accessError.cause = error;
throw accessError;
}
if (error?.code === GOOGLE_DRIVE_PLAYER_AMBIGUOUS) throw error;
// Fall through to the generic content script injection.
}
@@ -1946,18 +2084,34 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
}
await chrome.scripting.executeScript({
target: { tabId },
target: scriptTarget,
files: ['page-api-seek-overrides.js']
});
await chrome.scripting.executeScript({
target: { tabId },
target: scriptTarget,
func: setPageApiSeekEnabled,
args: [pageApiSeekReady]
});
return await chrome.scripting.executeScript({
target: { tabId },
const injectionResults = await chrome.scripting.executeScript({
target: scriptTarget,
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
});
const frameId = Array.isArray(scriptTarget.frameIds)
? normalizeFrameId(scriptTarget.frameIds[0])
: 0;
const frameResult = Array.isArray(injectionResults)
? injectionResults.find(result => normalizeFrameId(result?.frameId) === frameId)
: null;
if (activeTargetActivation?.tabId === tabId) {
activeTargetActivation.frameId = frameId;
activeTargetActivation.documentId = typeof frameResult?.documentId === 'string'
? frameResult.documentId
: null;
}
return {
frameId,
documentId: typeof frameResult?.documentId === 'string' ? frameResult.documentId : null
};
} catch (error) {
// A temporary activeTab grant is intentionally allowed to win: even if
// permissions.contains() reports false, a successful injection above is
@@ -2144,10 +2298,12 @@ async function activateTargetTab(tabId, tabTitle, {
const activationGeneration = ++targetActivationGeneration;
activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId };
const previousTabId = normalizeTabId(currentTabId);
const previousContentTarget = currentContentTarget();
let injectedContentTarget = { frameId: 0, documentId: null };
try {
try {
await injectContentScript(selectedTabId, { requestHostAccess });
injectedContentTarget = await injectContentScript(selectedTabId, { requestHostAccess });
} catch (error) {
if (activationGeneration !== targetActivationGeneration) {
if (error?.code === HOST_ACCESS_REQUIRED_STATUS
@@ -2163,6 +2319,7 @@ async function activateTargetTab(tabId, tabTitle, {
}
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
@@ -2172,6 +2329,8 @@ async function activateTargetTab(tabId, tabTitle, {
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
roomIdleSince,
lastContentHeartbeatAt: null
});
@@ -2209,7 +2368,7 @@ async function activateTargetTab(tabId, tabTitle, {
return { status: 'superseded' };
}
await applyAudioSettingsToTab(selectedTabId);
await applyAudioSettingsToTab(selectedTabId, injectedContentTarget);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
@@ -2224,8 +2383,22 @@ async function activateTargetTab(tabId, tabTitle, {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
if (previousTabId === selectedTabId && !sameContentTarget(previousContentTarget, injectedContentTarget)) {
sendMessageToFrame(
previousTabId,
previousContentTarget.frameId,
{ type: 'KOALASYNC_DEACTIVATE' },
null,
previousContentTarget.documentId
).catch(() => {});
}
currentTabId = selectedTabId;
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
currentTargetFrameId = normalizeFrameId(injectedContentTarget.frameId);
currentTargetDocumentId = typeof injectedContentTarget.documentId === 'string'
? injectedContentTarget.documentId
: null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId && previousTabId !== selectedTabId) {
@@ -2235,6 +2408,8 @@ async function activateTargetTab(tabId, tabTitle, {
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
currentTargetFrameId,
currentTargetDocumentId,
roomIdleSince,
lastContentHeartbeatAt
});
@@ -2274,9 +2449,8 @@ async function retryPendingTarget({ expectedRequestId = null, requireGrantedAcce
}
if (requireGrantedAccess) {
let access;
try {
access = await inspectTabHostAccess(chrome, pending.tabId);
await chrome.tabs.get(pending.tabId);
} catch {
await clearPendingTarget({
expectedRequestId: pending.requestId,
@@ -2284,7 +2458,8 @@ async function retryPendingTarget({ expectedRequestId = null, requireGrantedAcce
});
return { status: 'invalid_tab' };
}
if (access.granted !== true || access.originPattern !== pending.originPattern) {
const granted = await containsOriginPermission(chrome, pending.originPattern);
if (granted !== true) {
return { status: 'permission_not_granted' };
}
pending = await readPendingTarget();
@@ -2359,6 +2534,7 @@ if (chrome.tabs?.onRemoved?.addListener) {
if (isCurrent) {
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
}
@@ -2414,7 +2590,7 @@ if (chrome.tabs?.onRemoved?.addListener) {
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) {
if (!isCurrentTargetIdentity(tabId, targetGeneration)) return;
chrome.tabs.sendMessage(tabId, {
sendMessageToContentTab(tabId, {
type: 'SERVER_COMMAND',
action,
payload,
@@ -2489,7 +2665,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
function leaveOldRoomIfSwitching(newRoomId) {
if (currentRoom && currentRoom.roomId !== newRoomId) {
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_RESET' }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_RESET' }).catch(() => {});
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
forceDisconnect();
currentRoom = null;
@@ -2524,17 +2700,36 @@ function leaveOldRoomIfSwitching(newRoomId) {
function resetAudioProcessingInTab(tabId) {
if (!tabId) return;
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
return;
}
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
}
async function applyAudioSettingsToTab(tabId) {
async function applyAudioSettingsToTab(tabId, contentTarget = null) {
if (!tabId) return;
// Local-only: audioSettings are never read from storage.sync.
const data = await chrome.storage.local.get(['audioSettings']);
chrome.tabs.sendMessage(tabId, {
const message = {
action: 'APPLY_AUDIO_SETTINGS',
settings: data.audioSettings
}).catch(() => {});
};
if (contentTarget) {
sendMessageToFrame(
tabId,
contentTarget.frameId,
message,
null,
contentTarget.documentId
).catch(() => {});
return;
}
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
sendMessageToCurrentContent(message).catch(() => {});
return;
}
chrome.tabs.sendMessage(tabId, message).catch(() => {});
}
// --- Extension Message Listeners ---
@@ -2547,10 +2742,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local' || (!changes.roomId && !changes.chatKey)) return;
if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return;
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
invalidateChatSession();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
});
async function handleAsyncMessage(message, sender, sendResponse) {
@@ -2565,7 +2760,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
@@ -2593,6 +2788,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION
});
}
@@ -2611,6 +2807,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await retryPendingTarget();
}
const pendingTarget = await readPendingTarget();
const settings = await getSettings();
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
@@ -2641,12 +2838,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
amController: amController(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
chatSupported: serverSupports(CAPABILITIES.CHAT),
hasChatKey: !!(await getSettings()).chatKey
chatSupported: serverSupportsChat(),
hasChatKey: !!settings.chatKey,
chatEnabled: settings.chatEnabled
});
} else if (message.type === 'GET_CHAT_CONTEXT') {
const senderTabId = sender.tab?.id;
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) {
sendResponse({ supported: false, hasKey: false });
return;
}
@@ -2658,7 +2855,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return value === key ? '' : value;
};
sendResponse({
supported: serverSupports(CAPABILITIES.CHAT),
supported: serverSupportsChat(),
enabled: settings.chatEnabled,
hasKey: !!settings.chatKey,
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
peerId,
@@ -2681,12 +2879,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
});
} else if (message.type === 'CHAT_SEND') {
const senderTabId = sender.tab?.id;
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) {
sendResponse({ status: 'invalid_tab' });
return;
}
if (!serverSupports(CAPABILITIES.CHAT)) {
if (!serverSupportsChat()) {
sendResponse({ status: 'unsupported' });
return;
}
@@ -2702,6 +2899,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
const settings = await getSettings();
if (!settings.chatEnabled) {
sendResponse({ status: 'disabled' });
return;
}
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
return;
@@ -2769,7 +2970,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// "Solo" to the host (stale-badge split-brain).
sendResponse({ controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) });
} else if (message.type === 'REQUEST_HOST_SYNC') {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
if (sender.tab && !isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab', target: null });
return;
}
@@ -2796,7 +2997,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'HCM_DESYNC_STATE') {
// content.js tells us whether the local user chose to watch on their own.
// Only accept from the currently selected tab.
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
if (sender.tab && !isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -2824,10 +3025,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null;
lastContentHeartbeatAt = null;
@@ -2914,7 +3116,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
if (!isCurrentJoin()) {
sendResponse({ status: 'superseded' });
@@ -2945,6 +3147,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION
});
}
@@ -2972,12 +3175,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ error: 'No tabId provided' });
return;
}
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
if (chrome.runtime.lastError) {
sendResponse({ error: chrome.runtime.lastError.message });
} else {
sendResponse(res);
}
getReadyTabVideoState(tabId).then(res => {
sendResponse(res);
}).catch(error => {
sendResponse({ error: error.message });
});
} else if (message.type === 'DEV_SIMULATE_REMOTE_SEEK') {
if (!(await devRemoteToolsAllowed())) {
@@ -3013,11 +3214,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
HOST_ONLY_GATED_ACTIONS.includes(message.action)) {
addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn');
if (sender.tab && sender.tab.id) {
chrome.tabs.sendMessage(sender.tab.id, {
sendMessageToFrame(sender.tab.id, sender.frameId, {
type: 'HOST_BLOCKED',
action: message.action,
target: getHostSyncTarget()
}).catch(() => {});
}, null, sender.documentId).catch(() => {});
}
sendResponse({ status: 'blocked_host_only' });
return;
@@ -3125,9 +3326,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
};
if (sender.tab) {
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
if (!isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -3146,7 +3345,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
});
}
} else if (message.type === 'FORCE_SYNC_ACK') {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
if (sender.tab && !isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -3174,7 +3373,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
if (sender.tab && !isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -3194,9 +3393,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
} else if (message.type === 'HEARTBEAT') {
if (sender.tab) {
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
if (!isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -3271,6 +3468,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
@@ -3281,6 +3479,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
roomIdleSince,
lastContentHeartbeatAt: null
});
@@ -3308,8 +3508,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'EPISODE_CHANGED') {
// Content script detected an episode transition
if (sender.tab) {
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
if (!isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -3394,10 +3593,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// 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, {
sendMessageToFrame(sender.tab.id, sender.frameId, {
type: 'PAUSE_FOR_LOBBY',
expectedTitle: lobbyTitle
}).catch(() => {});
}, null, sender.documentId).catch(() => {});
}
// Broadcast to room
@@ -3412,8 +3611,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'lobby_created' });
} else if (message.type === 'EPISODE_READY_LOCAL') {
if (sender.tab) {
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
if (!isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -3452,13 +3650,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
});
}
if (currentRoom && currentTabId) {
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {});
sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {});
}
sendResponse({ status: 'ok' });
} else if (message.type === 'DRIVE_FRAME_VISIBILITY') {
if (!isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_stale_frame' });
return;
}
if (message.visible !== false) {
sendResponse({ status: 'ok' });
return;
}
const tabId = normalizeTabId(sender.tab?.id);
const expectedGeneration = targetActivationGeneration;
const activation = tabId === null
? null
: await reactivateCurrentTarget(tabId, { expectedGeneration });
sendResponse(activation || { status: 'invalid_tab' });
} else if (message.type === 'CONTENT_BOOT') {
if (sender.tab) {
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
if (!isCurrentContentSender(sender)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -3485,7 +3697,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
initTabManager({
getCurrentTabId: () => currentTabId,
reactivateCurrentTarget,
ensureState
ensureState,
sendToCurrentContent: sendMessageToCurrentContent
});
// Initial Connect — only if user has an active room configuration
+14
View File
@@ -9,6 +9,8 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const localeDir = path.join(extensionDir, 'locales');
const chatKeys = [
'LABEL_CHAT_ENABLED',
'LABEL_CHAT_ENABLED_TOOLTIP',
'CHAT_TITLE',
'CHAT_LIVE_ONLY',
'CHAT_OPEN',
@@ -60,6 +62,18 @@ describe('chat overlay contract', () => {
expect(backgroundSource).toContain("status: 'rate_limited'");
});
it('keeps chat hidden by default without discarding the room chat key', () => {
expect(popupSource).toContain('localData.chatEnabled === true');
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
expect(backgroundSource).toContain('clientCapabilities: CLIENT_CAPABILITIES');
expect(overlaySource).toContain('all:initial;display:none;position:fixed');
expect(overlaySource).toContain("host.style.display = supported && optedIn ? '' : 'none'");
expect(overlaySource).toContain('supported && optedIn && hasKey && connected');
expect(popupSource).toContain("chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked })");
expect(popupSource).toMatch(/if \(isCreating\) \{[\s\S]*?type: 'CREATE_CHAT_KEY'[\s\S]*?chatKey = normalizeChatKey/);
expect(popupSource).not.toMatch(/chatEnabled[\s\S]{0,120}chatKey:\s*''/);
});
it('keeps unavailable chat controls discoverable to assistive technology', () => {
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
expect(overlaySource).not.toContain('launcher.disabled =');
+110 -16
View File
@@ -9,6 +9,11 @@
const MIN_HEIGHT = 320;
const DEFAULT_WIDTH = 360;
const DEFAULT_HEIGHT = 520;
const SIZE_PRESETS = Object.freeze({
compact: Object.freeze({ width: 320, height: 400 }),
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
large: Object.freeze({ width: 440, height: 640 })
});
const storageKey = `chatOverlayLayout:${location.origin}`;
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let context = null;
@@ -16,16 +21,30 @@
let destroyed = false;
let saveTimer = null;
let applyingLayout = false;
let preferencesLoaded = false;
let startStateApplied = false;
let refreshGeneration = 0;
let sendGeneration = 0;
let sending = false;
let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false };
let layout = {
mode: 'right',
x: 24,
y: 72,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
customWidth: DEFAULT_WIDTH,
customHeight: DEFAULT_HEIGHT,
detachedInitialized: false
};
let chatPosition = 'right';
let chatSize = 'standard';
let chatStartMode = 'bubble';
let themeMode = 'system';
let themePalette = 'eucalyptus';
const host = document.createElement('div');
host.id = 'koalasync-chat-overlay-host';
host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
host.style.cssText = 'all:initial;display:none;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
@@ -243,6 +262,21 @@
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
}
function normalizePosition(value) {
return ['left', 'right', 'detached'].includes(value) ? value : 'right';
}
function normalizeSize(value) {
return ['compact', 'standard', 'large', 'custom'].includes(value) ? value : 'standard';
}
function preferredSize() {
return SIZE_PRESETS[chatSize] || {
width: Number(layout.customWidth) || DEFAULT_WIDTH,
height: Number(layout.customHeight) || DEFAULT_HEIGHT
};
}
function clampDetached() {
const viewport = viewportBounds();
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
@@ -265,6 +299,7 @@
function applyLayout() {
applyingLayout = true;
const size = preferredSize();
panel.style.right = 'auto';
panel.style.left = 'auto';
panel.style.bottom = 'auto';
@@ -288,8 +323,8 @@
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
panel.style.top = `${panelTop}px`;
panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`;
panel.style.height = `${Math.max(1, Math.min(560, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
panel.style.width = `${Math.max(1, Math.min(size.width, viewport.width - gutter * 2))}px`;
panel.style.height = `${Math.max(1, Math.min(size.height, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
panel.style[layout.mode] = `${gutter}px`;
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
@@ -298,16 +333,35 @@
globalThis.queueMicrotask(() => { applyingLayout = false; });
}
function setMode(mode) {
if (!['left', 'right', 'detached'].includes(mode)) return;
function setMode(mode, persistPreference = true) {
mode = normalizePosition(mode);
if (mode === 'detached' && !layout.detachedInitialized) {
layout.x = Math.max(8, Math.round((window.innerWidth - DEFAULT_WIDTH) / 2));
layout.y = Math.max(8, Math.round((window.innerHeight - DEFAULT_HEIGHT) / 2));
const size = preferredSize();
layout.x = Math.max(8, Math.round((window.innerWidth - size.width) / 2));
layout.y = Math.max(8, Math.round((window.innerHeight - size.height) / 2));
layout.detachedInitialized = true;
}
chatPosition = mode;
layout.mode = mode;
applyLayout();
saveLayout();
if (persistPreference) chrome.storage.local.set({ chatPosition: mode }).catch(() => {});
}
function setSize(size, persistPreference = true) {
chatSize = normalizeSize(size);
const preset = SIZE_PRESETS[chatSize];
if (preset) {
layout.width = preset.width;
layout.height = preset.height;
} else {
layout.width = Number(layout.customWidth) || DEFAULT_WIDTH;
layout.height = Number(layout.customHeight) || DEFAULT_HEIGHT;
}
if (layout.mode === 'detached') clampDetached();
applyLayout();
saveLayout();
if (persistPreference) chrome.storage.local.set({ chatSize }).catch(() => {});
}
function setOpened(next) {
@@ -324,13 +378,23 @@
const previousRoomId = context?.roomId;
context = next || null;
const supported = !!context?.supported;
const optedIn = !!context?.enabled;
const hasKey = !!context?.hasKey;
const connected = !!context?.connected;
context = context ? { ...context, enabled: supported && hasKey && connected } : null;
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages();
host.style.display = supported ? '' : 'none';
context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
if (previousRoomId && previousRoomId !== context?.roomId) {
clearMessages();
startStateApplied = false;
}
host.style.display = supported && optedIn ? '' : 'none';
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!context?.enabled) setOpened(false);
if (!optedIn) startStateApplied = false;
if (!context?.enabled) {
setOpened(false);
} else if (preferencesLoaded && !startStateApplied) {
startStateApplied = true;
setOpened(chatStartMode === 'open');
}
applyStrings();
applyLayout();
}
@@ -450,8 +514,15 @@
if (applyingLayout || layout.mode !== 'detached') return;
const rect = panel.getBoundingClientRect();
if (!opened || !rect?.width || !rect.height) return;
if (Math.abs(rect.width - layout.width) < 1 && Math.abs(rect.height - layout.height) < 1) return;
layout.width = rect.width;
layout.height = rect.height;
layout.customWidth = rect.width;
layout.customHeight = rect.height;
if (chatSize !== 'custom') {
chatSize = 'custom';
chrome.storage.local.set({ chatSize }).catch(() => {});
}
clampDetached();
saveLayout();
});
@@ -480,6 +551,15 @@
if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system';
if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus';
if (changes.themeMode || changes.themePalette) applyTheme();
if (changes.chatPosition) setMode(changes.chatPosition.newValue, false);
if (changes.chatSize) setSize(changes.chatSize.newValue, false);
if (changes.chatStartMode) {
chatStartMode = changes.chatStartMode.newValue === 'open' ? 'open' : 'bubble';
if (context?.enabled) {
startStateApplied = true;
setOpened(chatStartMode === 'open');
}
}
if (changes.locale) refresh();
}
@@ -515,16 +595,30 @@
systemTheme.addEventListener('change', handleSystemTheme);
chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette'], data => {
if (data[storageKey] && typeof data[storageKey] === 'object') layout = { ...layout, ...data[storageKey] };
if (!['left', 'right', 'detached'].includes(layout.mode)) layout.mode = 'right';
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode'], data => {
const storedLayout = data[storageKey];
if (storedLayout && typeof storedLayout === 'object') {
layout = { ...layout, ...storedLayout };
layout.customWidth = Number(storedLayout.customWidth) || Number(storedLayout.width) || DEFAULT_WIDTH;
layout.customHeight = Number(storedLayout.customHeight) || Number(storedLayout.height) || DEFAULT_HEIGHT;
}
chatPosition = normalizePosition(data.chatPosition);
chatSize = normalizeSize(data.chatSize);
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
layout.mode = chatPosition;
if (layout.mode === 'detached') layout.detachedInitialized = true;
const preset = SIZE_PRESETS[chatSize];
if (preset) {
layout.width = preset.width;
layout.height = preset.height;
}
themeMode = data.themeMode || 'system';
themePalette = data.themePalette || 'eucalyptus';
preferencesLoaded = true;
applyTheme();
applyLayout();
refresh();
});
window.koalaSyncChatOverlay = { refresh, destroy };
refresh();
})();
+70 -25
View File
@@ -6,13 +6,15 @@
*/
(function() {
(function() {
// Injection Guard: Check if already injected AND context is valid
try {
try {
if (window.koalaSyncInjected && chrome.runtime.id) {
window.koalaSyncTargetActive = true;
return;
}
@@ -86,9 +88,34 @@
if (_suppressTimers[state]) {
clearTimeout(_suppressTimers[state]);
if (_suppressTimers[state]) {
delete _suppressTimers[state];
}
}
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
// from being relayed to peers as user-initiated seeks.
const MIN_SEEK_DELTA = 2.0;
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
let seekDebounceTimer = null; // debounce timer for rapid seek events
let expectedSeekTime = null; // strictly track programmatic seeks
const PAGE_API_SEEK_BRIDGE = 1;
// Accurate Disney+ playhead pushed by the MAIN-world page-API bridge
// (background.js installPageApiSeekBridge). The isolated content world
// can't read the page's media player directly.
let disneyPageApiTime = null;
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
@@ -851,11 +878,17 @@
document.addEventListener('DOMContentLoaded', retry, { once: true });
} else {
document.addEventListener('DOMContentLoaded', retry, { once: true });
} else {
setTimeout(retry, 50);
}
}
return;
}
const host = hcmEl('div', 'all:initial');
const root = host.attachShadow({ mode: 'open' });
@@ -864,9 +897,11 @@
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
}
const host = hcmEl('div', 'all:initial');
b.addEventListener('click', hcmExitDesync);
root.appendChild(b);
document.body.appendChild(host);
hcmBadgeHost = host;
@@ -951,8 +986,14 @@
}
if (candidates.length === 0) return null;
// Multiple videos found → pick the best one
if (candidates.length === 1) return candidates[0];
@@ -1136,9 +1177,10 @@
const ctx = initAudioContext();
if (!ctx) return null;
src.connect(dryGain);
dryGain.connect(ctx.destination);
src.connect(compressor);
compressor.connect(compGain);
@@ -1271,8 +1313,9 @@
}
chain.compressor.release.value = params.release ?? 0.300;
}
// --- Episode Auto-Sync: Detection ---
@@ -1308,8 +1351,9 @@
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
// Extract a canonical episode identifier from a title string.
return null;
}
@@ -1552,8 +1596,9 @@
matches() { return isGoogleDrivePlayerFrame(); },
playPauseButtonSelector: '.ytp-play-button'
},
lobbyPollTimer = null;
{
name: 'twitch-player-buttons',
urls: ['twitch.tv'],
playPauseButtonSelector: '[data-a-target="player-play-pause-button"]'
}
];
+12
View File
@@ -150,6 +150,18 @@ export function requestOriginPermission(chromeApi, originPattern) {
).catch(() => false);
}
export function containsOriginPermission(chromeApi, originPattern) {
if (typeof originPattern !== 'string' || !originPattern) {
return Promise.resolve(null);
}
return callBooleanPermissionMethod(
chromeApi,
'contains',
{ origins: [originPattern] },
{ timeoutMs: 1000 }
).catch(() => null);
}
export function isHostAccessError(error) {
const message = typeof error?.message === 'string' ? error.message : String(error || '');
return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message);
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.",
"LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an.",
"LABEL_CHAT_ENABLED": "Raum-Chat aktivieren",
"LABEL_CHAT_ENABLED_TOOLTIP": "Verschlüsselten Raum-Chat im ausgewählten Video-Tab anzeigen.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Darstellung und Position des verschlüsselten Raum-Chats festlegen.",
"CHAT_SETTINGS_NOTE": "Optional und Ende-zu-Ende verschlüsselt. Nachrichten sind nur live sichtbar und werden vom Relay nicht gespeichert.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Lege fest, wo der Chat am ausgewählten Player angezeigt wird.",
"LABEL_CHAT_SIZE": "Overlay-Größe",
"LABEL_CHAT_SIZE_TOOLTIP": "Wähle die Standardgröße des Chat-Overlays.",
"OPTION_CHAT_SIZE_COMPACT": "Kompakt",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Groß",
"OPTION_CHAT_SIZE_CUSTOM": "Letzte eigene Größe",
"LABEL_CHAT_START_MODE": "Startansicht",
"LABEL_CHAT_START_MODE_TOOLTIP": "Lege fest, ob der Chat als schwebende Blase oder geöffnetes Overlay startet.",
"OPTION_CHAT_START_BUBBLE": "Schwebende Chatblase",
"OPTION_CHAT_START_OPEN": "Geöffnetes Overlay",
"CHAT_TITLE": "Raum-Chat",
"CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.",
"CHAT_OPEN": "Raum-Chat öffnen",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.",
"LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences.",
"LABEL_CHAT_ENABLED": "Enable Room Chat",
"LABEL_CHAT_ENABLED_TOOLTIP": "Show encrypted room chat on the selected video tab.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure encrypted room chat appearance and placement.",
"CHAT_SETTINGS_NOTE": "Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Choose where chat is attached to the selected player.",
"LABEL_CHAT_SIZE": "Overlay size",
"LABEL_CHAT_SIZE_TOOLTIP": "Choose the default size of the chat overlay.",
"OPTION_CHAT_SIZE_COMPACT": "Compact",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Large",
"OPTION_CHAT_SIZE_CUSTOM": "Last custom size",
"LABEL_CHAT_START_MODE": "Start as",
"LABEL_CHAT_START_MODE_TOOLTIP": "Choose whether chat starts as a floating bubble or an open overlay.",
"OPTION_CHAT_START_BUBBLE": "Floating bubble",
"OPTION_CHAT_START_OPEN": "Open overlay",
"CHAT_TITLE": "Room Chat",
"CHAT_LIVE_ONLY": "Live only. No history.",
"CHAT_OPEN": "Open room chat",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Cambia tu nombre de usuario, tema y preferencias de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reproducción y Sincronización",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio.",
"LABEL_CHAT_ENABLED": "Activar chat de la sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar el chat cifrado de la sala en la pestaña de vídeo seleccionada.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configura la apariencia y la posición del chat cifrado de la sala.",
"CHAT_SETTINGS_NOTE": "Opcional y cifrado de extremo a extremo. Los mensajes solo se muestran en directo y el relé no los almacena.",
"LABEL_CHAT_POSITION": "Posición",
"LABEL_CHAT_POSITION_TOOLTIP": "Elige dónde se acopla el chat al reproductor seleccionado.",
"LABEL_CHAT_SIZE": "Tamaño de la superposición",
"LABEL_CHAT_SIZE_TOOLTIP": "Elige el tamaño predeterminado de la superposición del chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Estándar",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamaño personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Elige si el chat comienza como una burbuja flotante o una superposición abierta.",
"OPTION_CHAT_START_BUBBLE": "Burbuja flotante",
"OPTION_CHAT_START_OPEN": "Superposición abierta",
"CHAT_TITLE": "Chat de la sala",
"CHAT_LIVE_ONLY": "Solo en directo. Sin historial.",
"CHAT_OPEN": "Abrir chat de la sala",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifiez votre nom d'utilisateur, votre thème et vos préférences linguistiques.",
"LABEL_SETTINGS_GROUP_SYNC": "Lecture & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio.",
"LABEL_CHAT_ENABLED": "Activer le chat du salon",
"LABEL_CHAT_ENABLED_TOOLTIP": "Afficher le chat chiffré du salon dans l'onglet vidéo sélectionné.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configurez l'apparence et l'emplacement du chat chiffré du salon.",
"CHAT_SETTINGS_NOTE": "Facultatif et chiffré de bout en bout. Les messages sont uniquement en direct et ne sont pas stockés par le relais.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Choisissez où le chat est ancré au lecteur sélectionné.",
"LABEL_CHAT_SIZE": "Taille de la fenêtre",
"LABEL_CHAT_SIZE_TOOLTIP": "Choisissez la taille par défaut de la fenêtre de chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacte",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Dernière taille personnalisée",
"LABEL_CHAT_START_MODE": "Affichage initial",
"LABEL_CHAT_START_MODE_TOOLTIP": "Choisissez si le chat démarre sous forme de bulle flottante ou de fenêtre ouverte.",
"OPTION_CHAT_START_BUBBLE": "Bulle flottante",
"OPTION_CHAT_START_OPEN": "Fenêtre ouverte",
"CHAT_TITLE": "Chat du salon",
"CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.",
"CHAT_OPEN": "Ouvrir le chat du salon",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.",
"LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio.",
"LABEL_CHAT_ENABLED": "Attiva chat della stanza",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostra la chat crittografata della stanza nella scheda video selezionata.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configura l'aspetto e la posizione della chat crittografata della stanza.",
"CHAT_SETTINGS_NOTE": "Opzionale e crittografata end-to-end. I messaggi sono solo in diretta e non vengono memorizzati dal relay.",
"LABEL_CHAT_POSITION": "Posizione",
"LABEL_CHAT_POSITION_TOOLTIP": "Scegli dove agganciare la chat al lettore selezionato.",
"LABEL_CHAT_SIZE": "Dimensione overlay",
"LABEL_CHAT_SIZE_TOOLTIP": "Scegli la dimensione predefinita dell'overlay della chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compatto",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Ultima dimensione personalizzata",
"LABEL_CHAT_START_MODE": "Avvio come",
"LABEL_CHAT_START_MODE_TOOLTIP": "Scegli se la chat si avvia come bolla mobile o overlay aperto.",
"OPTION_CHAT_START_BUBBLE": "Bolla mobile",
"OPTION_CHAT_START_OPEN": "Overlay aperto",
"CHAT_TITLE": "Chat della stanza",
"CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
"CHAT_OPEN": "Apri la chat della stanza",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。",
"LABEL_CHAT_ENABLED": "ルームチャットを有効化",
"LABEL_CHAT_ENABLED_TOOLTIP": "選択した動画タブに暗号化されたルームチャットを表示します。",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "暗号化されたルームチャットの外観と位置を設定します。",
"CHAT_SETTINGS_NOTE": "任意で利用でき、エンドツーエンドで暗号化されます。メッセージはリアルタイムのみで、リレーには保存されません。",
"LABEL_CHAT_POSITION": "位置",
"LABEL_CHAT_POSITION_TOOLTIP": "選択したプレーヤーのどこにチャットを配置するか選択します。",
"LABEL_CHAT_SIZE": "オーバーレイサイズ",
"LABEL_CHAT_SIZE_TOOLTIP": "チャットオーバーレイの標準サイズを選択します。",
"OPTION_CHAT_SIZE_COMPACT": "コンパクト",
"OPTION_CHAT_SIZE_STANDARD": "標準",
"OPTION_CHAT_SIZE_LARGE": "大",
"OPTION_CHAT_SIZE_CUSTOM": "前回のカスタムサイズ",
"LABEL_CHAT_START_MODE": "開始表示",
"LABEL_CHAT_START_MODE_TOOLTIP": "フローティングバブルまたは開いたオーバーレイで開始するか選択します。",
"OPTION_CHAT_START_BUBBLE": "フローティングバブル",
"OPTION_CHAT_START_OPEN": "開いたオーバーレイ",
"CHAT_TITLE": "ルームチャット",
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
"CHAT_OPEN": "ルームチャットを開く",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.",
"LABEL_CHAT_ENABLED": "방 채팅 사용",
"LABEL_CHAT_ENABLED_TOOLTIP": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "암호화된 방 채팅의 모양과 위치를 설정합니다.",
"CHAT_SETTINGS_NOTE": "선택 기능이며 종단 간 암호화됩니다. 메시지는 실시간으로만 표시되고 릴레이에 저장되지 않습니다.",
"LABEL_CHAT_POSITION": "위치",
"LABEL_CHAT_POSITION_TOOLTIP": "선택한 플레이어에서 채팅을 고정할 위치를 선택합니다.",
"LABEL_CHAT_SIZE": "오버레이 크기",
"LABEL_CHAT_SIZE_TOOLTIP": "채팅 오버레이의 기본 크기를 선택합니다.",
"OPTION_CHAT_SIZE_COMPACT": "작게",
"OPTION_CHAT_SIZE_STANDARD": "표준",
"OPTION_CHAT_SIZE_LARGE": "크게",
"OPTION_CHAT_SIZE_CUSTOM": "마지막 사용자 지정 크기",
"LABEL_CHAT_START_MODE": "시작 화면",
"LABEL_CHAT_START_MODE_TOOLTIP": "플로팅 버블 또는 열린 오버레이로 시작할지 선택합니다.",
"OPTION_CHAT_START_BUBBLE": "플로팅 채팅 버블",
"OPTION_CHAT_START_OPEN": "열린 오버레이",
"CHAT_TITLE": "방 채팅",
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
"CHAT_OPEN": "방 채팅 열기",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.",
"LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan.",
"LABEL_CHAT_ENABLED": "Kamerchat inschakelen",
"LABEL_CHAT_ENABLED_TOOLTIP": "Versleutelde kamerchat tonen in het geselecteerde videotabblad.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Pas het uiterlijk en de positie van de versleutelde kamerchat aan.",
"CHAT_SETTINGS_NOTE": "Optioneel en end-to-end versleuteld. Berichten zijn alleen live en worden niet door de relay opgeslagen.",
"LABEL_CHAT_POSITION": "Positie",
"LABEL_CHAT_POSITION_TOOLTIP": "Kies waar de chat aan de geselecteerde speler wordt gekoppeld.",
"LABEL_CHAT_SIZE": "Overlaygrootte",
"LABEL_CHAT_SIZE_TOOLTIP": "Kies de standaardgrootte van de chatoverlay.",
"OPTION_CHAT_SIZE_COMPACT": "Compact",
"OPTION_CHAT_SIZE_STANDARD": "Standaard",
"OPTION_CHAT_SIZE_LARGE": "Groot",
"OPTION_CHAT_SIZE_CUSTOM": "Laatste aangepaste grootte",
"LABEL_CHAT_START_MODE": "Start als",
"LABEL_CHAT_START_MODE_TOOLTIP": "Kies of de chat start als zwevende knop of als geopende overlay.",
"OPTION_CHAT_START_BUBBLE": "Zwevende chatknop",
"OPTION_CHAT_START_OPEN": "Geopende overlay",
"CHAT_TITLE": "Kamerchat",
"CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
"CHAT_OPEN": "Kamerchat openen",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.",
"LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio.",
"LABEL_CHAT_ENABLED": "Włącz czat pokoju",
"LABEL_CHAT_ENABLED_TOOLTIP": "Pokaż zaszyfrowany czat pokoju na wybranej karcie wideo.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Skonfiguruj wygląd i położenie zaszyfrowanego czatu pokoju.",
"CHAT_SETTINGS_NOTE": "Opcjonalny i szyfrowany od końca do końca. Wiadomości są tylko na żywo i nie są przechowywane przez serwer pośredniczący.",
"LABEL_CHAT_POSITION": "Położenie",
"LABEL_CHAT_POSITION_TOOLTIP": "Wybierz, gdzie czat ma być przypięty do wybranego odtwarzacza.",
"LABEL_CHAT_SIZE": "Rozmiar nakładki",
"LABEL_CHAT_SIZE_TOOLTIP": "Wybierz domyślny rozmiar nakładki czatu.",
"OPTION_CHAT_SIZE_COMPACT": "Kompaktowy",
"OPTION_CHAT_SIZE_STANDARD": "Standardowy",
"OPTION_CHAT_SIZE_LARGE": "Duży",
"OPTION_CHAT_SIZE_CUSTOM": "Ostatni własny rozmiar",
"LABEL_CHAT_START_MODE": "Uruchom jako",
"LABEL_CHAT_START_MODE_TOOLTIP": "Wybierz, czy czat ma startować jako pływający przycisk, czy otwarta nakładka.",
"OPTION_CHAT_START_BUBBLE": "Pływający przycisk",
"OPTION_CHAT_START_OPEN": "Otwarta nakładka",
"CHAT_TITLE": "Czat pokoju",
"CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.",
"CHAT_OPEN": "Otwórz czat pokoju",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de usuário, tema e preferências de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat criptografado da sala na guia de vídeo selecionada.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure a aparência e a posição do chat criptografado da sala.",
"CHAT_SETTINGS_NOTE": "Opcional e criptografado de ponta a ponta. As mensagens são apenas ao vivo e não são armazenadas pelo relay.",
"LABEL_CHAT_POSITION": "Posição",
"LABEL_CHAT_POSITION_TOOLTIP": "Escolha onde o chat fica fixado no player selecionado.",
"LABEL_CHAT_SIZE": "Tamanho da sobreposição",
"LABEL_CHAT_SIZE_TOOLTIP": "Escolha o tamanho padrão da sobreposição do chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Padrão",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamanho personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
"CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de utilizador, tema e preferências de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat encriptado da sala no separador de vídeo selecionado.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure o aspeto e a posição do chat encriptado da sala.",
"CHAT_SETTINGS_NOTE": "Opcional e encriptado ponto a ponto. As mensagens são apenas em direto e não são guardadas pelo relay.",
"LABEL_CHAT_POSITION": "Posição",
"LABEL_CHAT_POSITION_TOOLTIP": "Escolha onde o chat fica fixado no leitor selecionado.",
"LABEL_CHAT_SIZE": "Tamanho da sobreposição",
"LABEL_CHAT_SIZE_TOOLTIP": "Escolha o tamanho predefinido da sobreposição do chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Padrão",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamanho personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
"CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.",
"LABEL_CHAT_ENABLED": "Включить чат комнаты",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Настройте внешний вид и расположение зашифрованного чата комнаты.",
"CHAT_SETTINGS_NOTE": "Необязательный чат со сквозным шифрованием. Сообщения доступны только в реальном времени и не сохраняются ретранслятором.",
"LABEL_CHAT_POSITION": "Положение",
"LABEL_CHAT_POSITION_TOOLTIP": "Выберите, где чат будет закреплён на выбранном проигрывателе.",
"LABEL_CHAT_SIZE": "Размер окна",
"LABEL_CHAT_SIZE_TOOLTIP": "Выберите стандартный размер окна чата.",
"OPTION_CHAT_SIZE_COMPACT": "Компактный",
"OPTION_CHAT_SIZE_STANDARD": "Стандартный",
"OPTION_CHAT_SIZE_LARGE": "Большой",
"OPTION_CHAT_SIZE_CUSTOM": "Последний пользовательский размер",
"LABEL_CHAT_START_MODE": "Начальный вид",
"LABEL_CHAT_START_MODE_TOOLTIP": "Выберите, запускать чат как плавающую кнопку или открытое окно.",
"OPTION_CHAT_START_BUBBLE": "Плавающая кнопка",
"OPTION_CHAT_START_OPEN": "Открытое окно",
"CHAT_TITLE": "Чат комнаты",
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
"CHAT_OPEN": "Открыть чат комнаты",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Kullanıcı adınızı, temanızı ve dil tercihlerinizi değiştirin.",
"LABEL_SETTINGS_GROUP_SYNC": "Oynatma ve Eşitleme",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin.",
"LABEL_CHAT_ENABLED": "Oda sohbetini etkinleştir",
"LABEL_CHAT_ENABLED_TOOLTIP": "Şifreli oda sohbetini seçili video sekmesinde göster.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Şifreli oda sohbetinin görünümünü ve konumunu yapılandırın.",
"CHAT_SETTINGS_NOTE": "İsteğe bağlı ve uçtan uca şifrelidir. Mesajlar yalnızca canlıdır ve aktarıcı tarafından saklanmaz.",
"LABEL_CHAT_POSITION": "Konum",
"LABEL_CHAT_POSITION_TOOLTIP": "Sohbetin seçili oynatıcıda nereye sabitleneceğini seçin.",
"LABEL_CHAT_SIZE": "Kaplama boyutu",
"LABEL_CHAT_SIZE_TOOLTIP": "Sohbet kaplamasının varsayılan boyutunu seçin.",
"OPTION_CHAT_SIZE_COMPACT": "Kompakt",
"OPTION_CHAT_SIZE_STANDARD": "Standart",
"OPTION_CHAT_SIZE_LARGE": "Büyük",
"OPTION_CHAT_SIZE_CUSTOM": "Son özel boyut",
"LABEL_CHAT_START_MODE": "Başlangıç görünümü",
"LABEL_CHAT_START_MODE_TOOLTIP": "Sohbetin kayan balon veya açık kaplama olarak başlamasını seçin.",
"OPTION_CHAT_START_BUBBLE": "Kayan sohbet balonu",
"OPTION_CHAT_START_OPEN": "Açık kaplama",
"CHAT_TITLE": "Oda Sohbeti",
"CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
"CHAT_OPEN": "Oda sohbetini aç",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.",
"LABEL_CHAT_ENABLED": "Увімкнути чат кімнати",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Налаштуйте вигляд і розташування зашифрованого чату кімнати.",
"CHAT_SETTINGS_NOTE": "Необов'язковий чат із наскрізним шифруванням. Повідомлення доступні лише наживо й не зберігаються ретранслятором.",
"LABEL_CHAT_POSITION": "Розташування",
"LABEL_CHAT_POSITION_TOOLTIP": "Виберіть, де чат буде закріплено на вибраному програвачі.",
"LABEL_CHAT_SIZE": "Розмір вікна",
"LABEL_CHAT_SIZE_TOOLTIP": "Виберіть стандартний розмір вікна чату.",
"OPTION_CHAT_SIZE_COMPACT": "Компактний",
"OPTION_CHAT_SIZE_STANDARD": "Стандартний",
"OPTION_CHAT_SIZE_LARGE": "Великий",
"OPTION_CHAT_SIZE_CUSTOM": "Останній власний розмір",
"LABEL_CHAT_START_MODE": "Початковий вигляд",
"LABEL_CHAT_START_MODE_TOOLTIP": "Виберіть, запускати чат як плаваючу кнопку чи відкрите вікно.",
"OPTION_CHAT_START_BUBBLE": "Плаваюча кнопка",
"OPTION_CHAT_START_OPEN": "Відкрите вікно",
"CHAT_TITLE": "Чат кімнати",
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
"CHAT_OPEN": "Відкрити чат кімнати",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。",
"LABEL_CHAT_ENABLED": "启用房间聊天",
"LABEL_CHAT_ENABLED_TOOLTIP": "在选定的视频标签页中显示加密的房间聊天。",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "配置加密房间聊天的外观和位置。",
"CHAT_SETTINGS_NOTE": "可选并采用端到端加密。消息仅实时显示,且不会由中继服务器存储。",
"LABEL_CHAT_POSITION": "位置",
"LABEL_CHAT_POSITION_TOOLTIP": "选择聊天在当前播放器上的固定位置。",
"LABEL_CHAT_SIZE": "浮层大小",
"LABEL_CHAT_SIZE_TOOLTIP": "选择聊天浮层的默认大小。",
"OPTION_CHAT_SIZE_COMPACT": "紧凑",
"OPTION_CHAT_SIZE_STANDARD": "标准",
"OPTION_CHAT_SIZE_LARGE": "大",
"OPTION_CHAT_SIZE_CUSTOM": "上次自定义大小",
"LABEL_CHAT_START_MODE": "启动方式",
"LABEL_CHAT_START_MODE_TOOLTIP": "选择以悬浮气泡或打开的浮层启动聊天。",
"OPTION_CHAT_START_BUBBLE": "悬浮聊天气泡",
"OPTION_CHAT_START_OPEN": "打开浮层",
"CHAT_TITLE": "房间聊天",
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
"CHAT_OPEN": "打开房间聊天",
+1 -1
View File
@@ -3,7 +3,7 @@
"default_locale": "en",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "2.6.4",
"version": "3.0.0",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
+262
View File
@@ -0,0 +1,262 @@
const GOOGLE_DRIVE_HOST = 'drive.google.com';
const GOOGLE_DRIVE_PLAYER_HOST = 'youtube.googleapis.com';
export const GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN = 'https://youtube.googleapis.com/*';
export const GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED = 'google_drive_player_access_required';
export const GOOGLE_DRIVE_PLAYER_AMBIGUOUS = 'google_drive_player_ambiguous';
export function isGoogleDriveUrl(value) {
try {
return new URL(value).hostname.toLowerCase() === GOOGLE_DRIVE_HOST;
} catch {
return false;
}
}
export function inspectMediaFrame() {
const videos = Array.from(document.querySelectorAll('video'));
const drivePlayerIframes = Array.from(document.querySelectorAll('iframe')).filter((frame) => {
try {
const url = new URL(frame.src);
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com') return false;
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
return origin === 'https://drive.google.com';
} catch {
return false;
}
});
let bestVideoScore = 0;
for (const video of videos) {
const width = video.videoWidth || video.offsetWidth || 0;
const height = video.videoHeight || video.offsetHeight || 0;
const duration = Number.isFinite(video.duration) ? video.duration : 0;
bestVideoScore = Math.max(bestVideoScore, (width * height) + (duration * 100));
}
return {
href: window.location.href,
videoCount: videos.length,
videoScore: bestVideoScore,
frameArea: Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight),
drivePlayerIframeCount: drivePlayerIframes.length,
parentFrameVisible: window.__koalaParentFrameVisibility?.visible ?? null,
parentFrameArea: window.__koalaParentFrameVisibility?.area ?? null
};
}
export function installParentFrameVisibilityProbe(token) {
const handler = (event) => {
if (event.source !== window.parent
|| event.data?.type !== 'KOALASYNC_FRAME_VISIBILITY'
|| event.data?.token !== token) {
return;
}
window.__koalaParentFrameVisibility = {
visible: event.data.visible === true,
area: Number.isFinite(event.data.area) ? event.data.area : 0
};
window.removeEventListener('message', handler);
};
window.addEventListener('message', handler);
}
export async function dispatchParentFrameVisibilityProbe(token) {
let count = 0;
for (const frame of document.querySelectorAll('iframe')) {
try {
const url = new URL(frame.src);
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com'
|| origin !== 'https://drive.google.com') {
continue;
}
const rect = frame.getBoundingClientRect();
const style = window.getComputedStyle(frame);
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
const intersectsViewport = rect.bottom > 0
&& rect.right > 0
&& rect.top < window.innerHeight
&& rect.left < window.innerWidth;
const browserReportsVisible = typeof frame.checkVisibility === 'function'
? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
: true;
const visible = area > 0
&& intersectsViewport
&& browserReportsVisible
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity) !== 0;
frame.contentWindow?.postMessage({
type: 'KOALASYNC_FRAME_VISIBILITY',
token,
visible,
area
}, 'https://youtube.googleapis.com');
count++;
} catch {
// Ignore unrelated or not-yet-initialized frames.
}
}
await new Promise(resolve => setTimeout(resolve, 100));
return count;
}
function isGoogleDrivePlayerFrameUrl(value) {
try {
const url = new URL(value);
if (url.hostname.toLowerCase() !== GOOGLE_DRIVE_PLAYER_HOST || url.pathname !== '/embed/') {
return false;
}
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
return origin === 'https://drive.google.com';
} catch {
return false;
}
}
function createGoogleDrivePlayerAccessError() {
const error = new Error('Google Drive player frame access is required');
error.code = GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED;
error.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN;
return error;
}
function createGoogleDrivePlayerAmbiguousError() {
const error = new Error('Google Drive player frame could not be identified safely');
error.code = GOOGLE_DRIVE_PLAYER_AMBIGUOUS;
return error;
}
export function selectGoogleDrivePlayerFrame(injectionResults) {
let candidates = (Array.isArray(injectionResults) ? injectionResults : [])
.filter(entry => Number.isInteger(entry?.frameId) && entry?.result)
.filter(entry => isGoogleDrivePlayerFrameUrl(entry.result.href));
if (candidates.length > 1) {
const confirmedVisible = candidates.filter(entry => entry.result.parentFrameVisible === true);
if (confirmedVisible.length > 0) {
candidates = confirmedVisible;
} else {
const notConfirmedHidden = candidates.filter(entry =>
entry.result.parentFrameVisible !== false
);
if (notConfirmedHidden.length !== 1) return null;
candidates = notConfirmedHidden;
}
}
candidates.sort((left, right) => {
const visibilityRank = result => result.parentFrameVisible === true
? 2
: result.parentFrameVisible === false
? 0
: 1;
const leftVisible = visibilityRank(left.result);
const rightVisible = visibilityRank(right.result);
if (leftVisible !== rightVisible) return rightVisible - leftVisible;
const leftParentArea = Number.isFinite(left.result.parentFrameArea)
? left.result.parentFrameArea
: left.result.frameArea;
const rightParentArea = Number.isFinite(right.result.parentFrameArea)
? right.result.parentFrameArea
: right.result.frameArea;
if (leftParentArea !== rightParentArea) return rightParentArea - leftParentArea;
const leftHasVideo = left.result.videoCount > 0 ? 1 : 0;
const rightHasVideo = right.result.videoCount > 0 ? 1 : 0;
if (leftHasVideo !== rightHasVideo) return rightHasVideo - leftHasVideo;
if (left.result.videoScore !== right.result.videoScore) {
return right.result.videoScore - left.result.videoScore;
}
return right.result.frameArea - left.result.frameArea;
});
return candidates[0] || null;
}
export async function resolveMediaScriptTarget(chromeApi, tabId, tabUrl, {
attempts = 8,
retryDelayMs = 200
} = {}) {
const topFrameTarget = { tabId };
if (!isGoogleDriveUrl(tabUrl)) return topFrameTarget;
let fallbackFrame = null;
let drivePlayerIframeDetected = false;
let ambiguousPlayerFramesDetected = false;
for (let attempt = 0; attempt < attempts; attempt++) {
let results;
try {
results = await chromeApi.scripting.executeScript({
target: { tabId, allFrames: true },
func: inspectMediaFrame
});
} catch {
let topFrameResult = null;
try {
const topResults = await chromeApi.scripting.executeScript({
target: { tabId },
func: inspectMediaFrame
});
topFrameResult = Array.isArray(topResults) ? topResults[0]?.result : null;
} catch {
// The generic injection path below remains the final source of truth.
}
if (topFrameResult?.drivePlayerIframeCount > 0) {
throw createGoogleDrivePlayerAccessError();
}
return topFrameTarget;
}
const topFrame = results.find(entry => entry?.frameId === 0);
if (topFrame?.result?.drivePlayerIframeCount > 0) {
drivePlayerIframeDetected = true;
}
const playerFrameCount = results.filter(entry =>
isGoogleDrivePlayerFrameUrl(entry?.result?.href)
).length;
if (playerFrameCount > 1) {
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
try {
await chromeApi.scripting.executeScript({
target: { tabId, allFrames: true },
func: installParentFrameVisibilityProbe,
args: [token]
});
await chromeApi.scripting.executeScript({
target: { tabId },
func: dispatchParentFrameVisibilityProbe,
args: [token]
});
results = await chromeApi.scripting.executeScript({
target: { tabId, allFrames: true },
func: inspectMediaFrame
});
} catch {
// Ambiguous frames are retried below; never guess a stale player.
}
}
const selectedFrame = selectGoogleDrivePlayerFrame(results);
if (!selectedFrame && playerFrameCount > 1) {
ambiguousPlayerFramesDetected = true;
}
if (selectedFrame?.result?.videoCount > 0) {
return { tabId, frameIds: [selectedFrame.frameId] };
}
if (selectedFrame) fallbackFrame = selectedFrame;
if (attempt < attempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
}
}
if (!fallbackFrame && ambiguousPlayerFramesDetected) {
throw createGoogleDrivePlayerAmbiguousError();
}
if (!fallbackFrame && drivePlayerIframeDetected) {
throw createGoogleDrivePlayerAccessError();
}
return fallbackFrame
? { tabId, frameIds: [fallbackFrame.frameId] }
: topFrameTarget;
}
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest';
import {
GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED,
GOOGLE_DRIVE_PLAYER_AMBIGUOUS,
isGoogleDriveUrl,
resolveMediaScriptTarget,
selectGoogleDrivePlayerFrame
} from './media-frame-target.js';
const drivePlayerUrl = 'https://youtube.googleapis.com/embed/?enablejsapi=1&origin=https%3A%2F%2Fdrive.google.com';
describe('Google Drive media-frame targeting', () => {
it('recognizes Drive tabs without treating other Google pages as Drive', () => {
expect(isGoogleDriveUrl('https://drive.google.com/drive/u/1/search?q=mp4')).toBe(true);
expect(isGoogleDriveUrl('https://docs.google.com/document/d/1/edit')).toBe(false);
});
it('selects the visible frame containing the real video', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000, parentFrameVisible: true } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
]);
expect(selected.frameId).toBe(4);
});
it('never lets a hidden loaded player outrank the visible Drive player', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000, parentFrameVisible: true, parentFrameArea: 900000 } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000, parentFrameVisible: false, parentFrameArea: 900000 } }
]);
expect(selected.frameId).toBe(4);
});
it('refuses to guess when parent visibility is unavailable', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
]);
expect(selected).toBeNull();
});
it('ignores unrelated video frames inside a Drive tab', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 3, result: { href: 'https://example.com/embed', videoCount: 1, videoScore: 3000000, frameArea: 1200000 } },
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } }
]);
expect(selected.frameId).toBe(4);
});
it('falls back to the visible Drive player frame while its video is loading', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000, parentFrameVisible: true } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
]);
expect(selected.frameId).toBe(4);
});
it('reports ambiguity instead of selecting a stale frame after retries', async () => {
const ambiguousResults = [
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
];
const executeScript = vi.fn().mockResolvedValue(ambiguousResults);
await expect(resolveMediaScriptTarget(
{ scripting: { executeScript } },
42,
'https://drive.google.com/drive/u/1/search?q=mp4',
{ attempts: 1, retryDelayMs: 0 }
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_AMBIGUOUS });
});
it('probes all frames and returns only the frame with the Drive video', async () => {
const executeScript = vi.fn()
.mockResolvedValueOnce([
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000 } }
])
.mockResolvedValueOnce([
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000 } }
]);
await expect(resolveMediaScriptTarget(
{ scripting: { executeScript } },
42,
'https://drive.google.com/drive/u/1/search?q=mp4',
{ retryDelayMs: 0 }
)).resolves.toEqual({ tabId: 42, frameIds: [4] });
expect(executeScript).toHaveBeenCalledWith({
target: { tabId: 42, allFrames: true },
func: expect.any(Function)
});
});
it('reports missing access when Drive exposes a player iframe but the frame cannot be inspected', async () => {
const executeScript = vi.fn().mockResolvedValue([
{
frameId: 0,
result: {
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
videoCount: 0,
videoScore: 0,
frameArea: 900000,
drivePlayerIframeCount: 1
}
}
]);
await expect(resolveMediaScriptTarget(
{ scripting: { executeScript } },
42,
'https://drive.google.com/drive/u/1/search?q=mp4',
{ attempts: 1, retryDelayMs: 0 }
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
});
it('reports missing access when the all-frame probe itself is rejected', async () => {
const executeScript = vi.fn()
.mockRejectedValueOnce(new Error('Cannot access contents of the frame'))
.mockResolvedValueOnce([
{
frameId: 0,
result: {
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
videoCount: 0,
videoScore: 0,
frameArea: 900000,
drivePlayerIframeCount: 1
}
}
]);
await expect(resolveMediaScriptTarget(
{ scripting: { executeScript } },
42,
'https://drive.google.com/drive/u/1/search?q=mp4',
{ attempts: 1, retryDelayMs: 0 }
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
});
});
+3 -2
View File
@@ -1,7 +1,8 @@
export function initTabManager({
getCurrentTabId,
reactivateCurrentTarget,
ensureState
ensureState,
sendToCurrentContent
}) {
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
@@ -9,7 +10,7 @@ export function initTabManager({
const tabId = getCurrentTabId();
if (!tabId) return;
chrome.tabs.sendMessage(tabId, {
sendToCurrentContent({
action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue
}).catch(() => {});
+52 -2
View File
@@ -1215,6 +1215,12 @@
line-height: 1.45;
text-align: left;
}
[data-chat-setting] {
transition: opacity 0.2s;
}
[data-chat-setting].is-disabled {
opacity: 0.5;
}
.settings-issue-link {
margin: 8px 0 2px;
padding-top: 9px;
@@ -1323,7 +1329,7 @@
}
@keyframes floatHint {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
50% { transform: translateY(-2px); }
}
</style>
</head>
@@ -1446,7 +1452,7 @@
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
</select>
<!-- Hint Tooltip -->
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px color-mix(in oklch, var(--accent), transparent 60%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
<div id="targetTabHint" style="display: none; position: absolute; top: -8px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 2px 6px color-mix(in oklch, var(--accent), transparent 82%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
Select your video here!
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
</div>
@@ -1615,6 +1621,50 @@
</div>
</details>
<details class="form-group" name="settings-accordion">
<summary title="Configure encrypted room chat appearance and placement." data-i18n="CHAT_TITLE" data-i18n-title="LABEL_SETTINGS_GROUP_CHAT_TOOLTIP">Room Chat</summary>
<div class="details-content">
<p class="settings-note" data-i18n="CHAT_SETTINGS_NOTE">Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.</p>
<div class="settings-row">
<label for="chatEnabled" title="Show encrypted room chat on the selected video tab." data-i18n="LABEL_CHAT_ENABLED" data-i18n-title="LABEL_CHAT_ENABLED_TOOLTIP">Enable Room Chat</label>
<label class="toggle-switch">
<input type="checkbox" id="chatEnabled">
<span class="slider"></span>
</label>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatPosition" title="Choose where chat is attached to the selected player." data-i18n="LABEL_CHAT_POSITION" data-i18n-title="LABEL_CHAT_POSITION_TOOLTIP">Position</label>
<span class="settings-select">
<select id="chatPosition" class="settings-control">
<option value="right" data-i18n="CHAT_DOCK_RIGHT">Dock chat right</option>
<option value="left" data-i18n="CHAT_DOCK_LEFT">Dock chat left</option>
<option value="detached" data-i18n="CHAT_DETACHED">Detach chat</option>
</select>
</span>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatSize" title="Choose the default size of the chat overlay." data-i18n="LABEL_CHAT_SIZE" data-i18n-title="LABEL_CHAT_SIZE_TOOLTIP">Overlay size</label>
<span class="settings-select">
<select id="chatSize" class="settings-control">
<option value="compact" data-i18n="OPTION_CHAT_SIZE_COMPACT">Compact</option>
<option value="standard" data-i18n="OPTION_CHAT_SIZE_STANDARD">Standard</option>
<option value="large" data-i18n="OPTION_CHAT_SIZE_LARGE">Large</option>
<option value="custom" data-i18n="OPTION_CHAT_SIZE_CUSTOM">Last custom size</option>
</select>
</span>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatStartMode" title="Choose whether chat starts as a floating bubble or an open overlay." data-i18n="LABEL_CHAT_START_MODE" data-i18n-title="LABEL_CHAT_START_MODE_TOOLTIP">Start as</label>
<span class="settings-select">
<select id="chatStartMode" class="settings-control">
<option value="bubble" data-i18n="OPTION_CHAT_START_BUBBLE">Floating bubble</option>
<option value="open" data-i18n="OPTION_CHAT_START_OPEN">Open overlay</option>
</select>
</span>
</div>
</div>
</details>
<details class="form-group" name="settings-accordion">
<summary title="Customize playback, notifications, and audio preferences." data-i18n="LABEL_SETTINGS_GROUP_SYNC" data-i18n-title="LABEL_SETTINGS_GROUP_SYNC_TOOLTIP">Playback &amp; Sync</summary>
<div class="details-content">
+52 -16
View File
@@ -1,11 +1,12 @@
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { BLACKLIST_ALLOWLIST_DOMAINS, BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
import { normalizeRoomId } from './chat-session.js';
import './shared/invite-links.js';
import { normalizeTabId, requestOriginPermission } from './host-access.js';
import { isTabUrlFiltered } from './tab-filter.js';
let pendingInviteRoomId = '';
let pendingInviteChatKey = '';
@@ -68,6 +69,10 @@ const elements = {
playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn'),
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
chatEnabled: document.getElementById('chatEnabled'),
chatPosition: document.getElementById('chatPosition'),
chatSize: document.getElementById('chatSize'),
chatStartMode: document.getElementById('chatStartMode'),
sendTabTitle: document.getElementById('sendTabTitle'),
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
@@ -337,7 +342,7 @@ function setRoomRefreshCooldown() {
async function init() {
// Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there).
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -368,6 +373,11 @@ async function init() {
syncDevToolsVisibility();
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
if (elements.chatPosition) elements.chatPosition.value = ['left', 'detached'].includes(localData.chatPosition) ? localData.chatPosition : 'right';
if (elements.chatSize) elements.chatSize.value = ['compact', 'large', 'custom'].includes(localData.chatSize) ? localData.chatSize : 'standard';
if (elements.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble';
syncChatSettingsState();
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
@@ -1148,18 +1158,9 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const filteredTabs = tabs.filter(tab => {
if (!tab.url || tab.url.startsWith('chrome://')) return false;
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
const urlStr = tab.url.toLowerCase();
if (BLACKLIST_DOMAINS.some(d => {
const domain = d.toLowerCase();
try {
const hostname = new URL(tab.url).hostname.toLowerCase();
if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
} catch {
/* ignore invalid URLs */
}
return urlStr.includes(domain);
})) return false;
if (isTabUrlFiltered(tab.url, BLACKLIST_DOMAINS, BLACKLIST_ALLOWLIST_DOMAINS)) {
return false;
}
}
return true;
});
@@ -1456,6 +1457,41 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
function syncChatSettingsState() {
const enabled = elements.chatEnabled?.checked === true;
for (const control of [elements.chatPosition, elements.chatSize, elements.chatStartMode]) {
if (control) control.disabled = !enabled;
}
document.querySelectorAll('[data-chat-setting]').forEach(row => {
row.classList.toggle('is-disabled', !enabled);
row.setAttribute('aria-disabled', String(!enabled));
});
}
if (elements.chatEnabled) {
elements.chatEnabled.addEventListener('change', () => {
syncChatSettingsState();
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
});
}
if (elements.chatPosition) {
elements.chatPosition.addEventListener('change', () => {
const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right';
chrome.storage.local.set({ chatPosition });
});
}
if (elements.chatSize) {
elements.chatSize.addEventListener('change', () => {
const chatSize = ['compact', 'large', 'custom'].includes(elements.chatSize.value) ? elements.chatSize.value : 'standard';
chrome.storage.local.set({ chatSize });
});
}
if (elements.chatStartMode) {
elements.chatStartMode.addEventListener('change', () => {
chrome.storage.local.set({ chatStartMode: elements.chatStartMode.value === 'open' ? 'open' : 'bubble' });
});
}
if (elements.sendTabTitle) {
elements.sendTabTitle.addEventListener('change', () => {
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
@@ -2029,7 +2065,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
failForceSyncTime();
return;
}
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (retryResponse) => {
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
failForceSyncTime();
return;
@@ -2037,7 +2073,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
sendForceSync(retryResponse.currentTime);
});
};
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (response) => {
if (Number.isFinite(response?.currentTime)) {
sendForceSync(response.currentTime);
return;
+22
View File
@@ -0,0 +1,22 @@
function hostnameMatchesDomain(hostname, domain) {
const normalizedHostname = String(hostname || '').toLowerCase();
const normalizedDomain = String(domain || '').toLowerCase();
return normalizedDomain
&& (normalizedHostname === normalizedDomain
|| normalizedHostname.endsWith(`.${normalizedDomain}`));
}
export function isTabUrlFiltered(url, blacklistDomains, allowlistDomains = []) {
const urlString = String(url || '');
try {
const hostname = new URL(urlString).hostname.toLowerCase();
if (allowlistDomains.some(domain => hostnameMatchesDomain(hostname, domain))) {
return false;
}
return blacklistDomains.some(domain => hostnameMatchesDomain(hostname, domain));
} catch {
const normalizedUrl = urlString.toLowerCase();
return blacklistDomains.some(domain => normalizedUrl.includes(String(domain).toLowerCase()));
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { isTabUrlFiltered } from './tab-filter.js';
const blacklist = ['google.com', 'mail.google.com'];
const allowlist = ['drive.google.com'];
describe('tab URL filtering', () => {
it('keeps Google Drive selectable despite the google.com parent-domain rule', () => {
expect(isTabUrlFiltered(
'https://drive.google.com/drive/u/1/search?q=mp4',
blacklist,
allowlist
)).toBe(false);
});
it('continues to filter other Google subdomains', () => {
expect(isTabUrlFiltered('https://mail.google.com/mail/u/0/', blacklist, allowlist)).toBe(true);
expect(isTabUrlFiltered('https://www.google.com/search?q=video', blacklist, allowlist)).toBe(true);
});
});
+783 -1254
View File
File diff suppressed because it is too large Load Diff
+17 -9
View File
@@ -1,9 +1,12 @@
{
"name": "koalasync",
"version": "2.6.4",
"version": "3.0.0",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"build:extension": "node scripts/build-extension.cjs",
"indexnow": "node website/submit-indexnow.cjs",
@@ -15,17 +18,22 @@
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.9",
"@playwright/test": "^1.62.0",
"@vitest/coverage-v8": "^4.1.10",
"addons-linter": "^10.9.0",
"archiver": "^7.0.1",
"archiver": "^8.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"eslint": "^10.8.0",
"eslint-plugin-no-unsanitized": "^4.1.5",
"fs-extra": "^11.2.0",
"fs-extra": "^11.4.0",
"htmlnano": "^3.4.0",
"sharp": "^0.34.5",
"svgo": "^4.0.1",
"vitest": "^4.1.9"
"sharp": "^0.35.3",
"svgo": "^4.0.2",
"vitest": "^4.1.10"
},
"overrides": {
"brace-expansion": "5.0.8",
"fast-uri": "3.1.4",
"postcss": "8.5.18"
}
}
+8 -9
View File
@@ -1,6 +1,5 @@
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const rootDir = path.join(__dirname, '..');
const extDir = path.join(rootDir, 'extension');
@@ -202,18 +201,18 @@ function copyExtensionFiles(targetDir, browserName) {
}
// Helper to zip a directory
function zipDirectory(sourceDir, outPath) {
async function zipDirectory(sourceDir, outPath) {
const { ZipArchive } = await import('archiver');
return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const archive = new ZipArchive({ zlib: { level: 9 } });
const stream = fs.createWriteStream(outPath);
archive
.directory(sourceDir, false)
.on('error', err => reject(err))
.pipe(stream);
archive.on('error', reject);
stream.on('close', () => resolve());
archive.finalize();
stream.on('error', reject);
archive.pipe(stream);
archive.directory(sourceDir, false);
void archive.finalize().catch(reject);
});
}
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const [popupHtml, popupJs, overlayJs, backgroundJs] = await Promise.all([
readFile(path.join(repoRoot, 'extension', 'popup.html'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'popup.js'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'chat-overlay.js'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'background.js'), 'utf8')
]);
function detailsSection(marker) {
const markerIndex = popupHtml.indexOf(marker);
assert.notEqual(markerIndex, -1, `missing settings marker: ${marker}`);
const start = popupHtml.lastIndexOf('<details', markerIndex);
const end = popupHtml.indexOf('</details>', markerIndex);
assert.ok(start >= 0 && end > markerIndex, `invalid details section: ${marker}`);
return popupHtml.slice(start, end + '</details>'.length);
}
const chatSection = detailsSection('data-i18n="CHAT_TITLE"');
const syncSection = detailsSection('data-i18n="LABEL_SETTINGS_GROUP_SYNC"');
for (const id of ['chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode']) {
assert.match(chatSection, new RegExp(`id="${id}"`), `${id} belongs in the dedicated chat settings section`);
}
assert.doesNotMatch(syncSection, /id="chatEnabled"/, 'chat enablement must not remain under Playback & Sync');
for (const value of ['right', 'left', 'detached']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat position supports ${value}`);
}
for (const value of ['compact', 'standard', 'large', 'custom']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat size supports ${value}`);
}
for (const value of ['bubble', 'open']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat start mode supports ${value}`);
}
for (const key of ['chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode']) {
assert.match(popupJs, new RegExp(`chrome\\.storage\\.local\\.set\\(\\{ ${key}`), `popup persists ${key}`);
assert.ok(backgroundJs.includes(`'${key}'`), `legacy sync purge includes ${key}`);
}
for (const key of ['chatPosition', 'chatSize', 'chatStartMode']) {
assert.ok(overlayJs.includes(`'${key}'`), `overlay reads ${key}`);
}
assert.match(backgroundJs, /chatEnabled:\s*data\.chatEnabled === true/, 'background reads chatEnabled with an off-by-default contract');
assert.match(overlayJs, /context\?\.enabled/, 'overlay consumes chat enablement from the validated background context');
assert.match(overlayJs, /SIZE_PRESETS[\s\S]*compact[\s\S]*standard[\s\S]*large/, 'overlay defines all size presets');
assert.match(
overlayJs,
/} else {\s*layout\.width = Number\(layout\.customWidth\) \|\| DEFAULT_WIDTH;\s*layout\.height = Number\(layout\.customHeight\) \|\| DEFAULT_HEIGHT;/,
'custom size restores its dedicated dimensions'
);
assert.match(overlayJs, /layout\.customWidth = rect\.width[\s\S]*layout\.customHeight = rect\.height/, 'detached resize updates the custom-size snapshot');
assert.match(overlayJs, /changes\.chatPosition[\s\S]*setMode/, 'position changes apply live');
assert.match(overlayJs, /changes\.chatSize[\s\S]*setSize/, 'size changes apply live');
assert.match(overlayJs, /changes\.chatStartMode[\s\S]*setOpened/, 'startup-mode changes apply live');
console.log('chat settings tests passed');
+26 -3
View File
@@ -98,14 +98,15 @@ function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null
].join('\n'))({ location: { hostname } }, document);
}
function loadPlayerFixFns(hostname) {
function loadPlayerFixFns(hostname, href = `https://${hostname}/`) {
return Function('window', [
extractFunction('hostMatchesUrl', source),
extractFunction('matchesPlayerUrls', source),
extractFunction('isGoogleDrivePlayerFrame', source),
extractFunction('getPlayerActionFixes', source),
extractFunction('getActivePlayerActionFix', source),
'return { getPlayerActionFixes, getActivePlayerActionFix };'
].join('\n'))({ location: { hostname } });
'return { isGoogleDrivePlayerFrame, getPlayerActionFixes, getActivePlayerActionFix };'
].join('\n'))({ location: { hostname, href } });
}
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
@@ -147,6 +148,28 @@ const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
const googleDriveFixFns = loadPlayerFixFns(
'youtube.googleapis.com',
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
);
assert.equal(googleDriveFixFns.getActivePlayerActionFix().name, 'google-drive-player-buttons');
assert.deepEqual(googleDriveFixFns.getActivePlayerActionFix().urls, ['youtube.googleapis.com']);
const unrelatedGoogleEmbedFixFns = loadPlayerFixFns(
'youtube.googleapis.com',
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fexample.com'
);
assert.equal(unrelatedGoogleEmbedFixFns.getActivePlayerActionFix(), null);
const googleDrivePlayerFns = Function('window', [
extractFunction('isGoogleDrivePlayerFrame', source),
'return { isGoogleDrivePlayerFrame };'
].join('\n'))({
location: {
hostname: 'youtube.googleapis.com',
href: 'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
}
});
assert.equal(googleDrivePlayerFns.isGoogleDrivePlayerFrame(), true);
const genericFixFns = loadPlayerFixFns('example.com');
assert.equal(genericFixFns.getActivePlayerActionFix(), null);
+12
View File
@@ -5,6 +5,7 @@ import { cwd } from 'node:process';
import {
HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest,
containsOriginPermission,
describeTabUrl,
inspectTabHostAccess,
isHostAccessError,
@@ -122,6 +123,17 @@ const callbackPermissionChrome = {
};
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
let containedOriginRequest = null;
assert.equal(await containsOriginPermission({
permissions: {
contains: async request => {
containedOriginRequest = request;
return true;
}
}
}, 'https://youtube.googleapis.com/*'), true);
assert.deepEqual(containedOriginRequest, { origins: ['https://youtube.googleapis.com/*'] });
assert.equal(await containsOriginPermission({ permissions: {} }, 'https://youtube.googleapis.com/*'), null);
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
+37 -2
View File
@@ -23,7 +23,10 @@ async function c() {
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
assert.equal((await a(ws))[0],'room_data');
}
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
// Test suite opens >10 connections/min — clear the IP connection counter so the
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
@@ -72,6 +75,7 @@ try {
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
'ROOM_DATA advertises the host-control capability');
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
close();
resetConnectionRate();
@@ -79,7 +83,8 @@ try {
// --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c();
await j(chat1, chatRoom, 'alice'); await j(chat2, chatRoom, 'bob');
await j(chat1, chatRoom, 'alice', null, ['chat-v1']);
await j(chat2, chatRoom, 'bob', null, ['chat-v1']);
chat1._m.length = chat2._m.length = 0;
const ciphertext = Buffer.alloc(64, 7).toString('base64url');
s(chat1, 'chat_message', {
@@ -106,6 +111,36 @@ try {
close();
resetConnectionRate();
// --- Mixed rollout: old non-chat clients never receive unknown chat events ---
const mixedChatRoom = 'chat-mixed-'+Date.now();
const newChat = await c(), oldNoChat = await c();
await j(newChat, mixedChatRoom, 'new-chat', null, ['chat-v1', 'chat-v1', 'future-chat', 42]);
await j(oldNoChat, mixedChatRoom, 'old-no-chat', null, ['chat-v2']);
newChat._m.length = oldNoChat._m.length = 0;
s(newChat, 'chat_message', { ciphertext });
await w(newChat, 'chat_message');
let oldReceivedChat = false;
try { await w(oldNoChat, 'chat_message', 500); oldReceivedChat = true; } catch { /* expected */ }
assert.equal(oldReceivedChat, false, 'old client receives no unknown chat event');
// The same old client remains fully usable for the pre-chat protocol.
newChat._m.length = oldNoChat._m.length = 0;
s(oldNoChat, 'play', { currentTime: 12 });
await w(newChat, 'play');
s(newChat, 'pause', { currentTime: 13 });
await w(oldNoChat, 'pause');
// First chat beta compatibility: sending a valid chat frame dynamically
// proves receive support even when that beta omitted clientCapabilities.
const firstBeta = await c();
await j(firstBeta, mixedChatRoom, 'first-beta');
firstBeta._m.length = newChat._m.length = 0;
s(firstBeta, 'chat_message', { ciphertext });
await w(firstBeta, 'chat_message');
await w(newChat, 'chat_message');
close();
resetConnectionRate();
// --- Default 'everyone' mode does NOT gate anyone (host-control OFF = unchanged) ---
// Confirms that with host-only off, a non-host guest can still drive every
// room-moving event exactly like before the feature existed.
+1
View File
@@ -20,6 +20,7 @@ const checks = [
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
+6 -5
View File
@@ -1,15 +1,16 @@
FROM node:20-alpine
FROM node:24-alpine
WORKDIR /app
# Copy shared protocol first
COPY shared/ ./shared/
COPY --chown=node:node shared/ ./shared/
# Copy server
COPY server/package*.json ./server/
RUN cd server && npm install --production
COPY server/ ./server/
COPY --chown=node:node server/package*.json ./server/
RUN cd server && npm ci --omit=dev
COPY --chown=node:node server/ ./server/
WORKDIR /app/server
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
+38 -2
View File
@@ -174,7 +174,26 @@ const HOST_ONLY_GATED_EVENTS = new Set([
// Features this relay supports, advertised to clients in ROOM_DATA so they can
// enable matching UI/behavior only when the server actually backs it. Append a
// flag here when a new server-gated feature ships (e.g. co-host promotion).
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST, CAPABILITIES.CHAT];
const SERVER_CAPABILITIES = [
CAPABILITIES.HOST_CONTROL,
CAPABILITIES.CO_HOST,
CAPABILITIES.CHAT,
CAPABILITIES.CHAT_V1
];
function normalizeClientCapabilities(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.slice(0, 16)
.filter(capability => typeof capability === 'string')
.map(capability => capability.substring(0, 32))
.filter(capability => capability === CAPABILITIES.CHAT_V1)
)];
}
function clientSupportsChat(socket) {
return Array.isArray(socket?.data?.clientCapabilities) &&
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
}
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
@@ -356,6 +375,7 @@ io.on('connection', (socket) => {
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;
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
@@ -370,6 +390,7 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
socket.data.clientCapabilities = clientCapabilities;
return; // Already in this room with same peerId, ignore to prevent spam
}
if (oldMapping && oldMapping.roomId !== roomId) {
@@ -493,6 +514,7 @@ io.on('connection', (socket) => {
}
socket.join(roomId);
socket.data.clientCapabilities = clientCapabilities;
room.peers.add(socket.id);
room.peerIds.set(socket.id, peerId);
room.peerData.set(socket.id, {
@@ -862,8 +884,22 @@ io.on('connection', (socket) => {
const envelope = createChatEnvelope(data, mapping.peerId);
if (!envelope) return;
// Transitional compatibility: the first chat beta did not announce
// clientCapabilities. Successfully sending a canonical chat frame is
// sufficient proof that this socket understands the v1 receive event.
if (!clientSupportsChat(socket)) {
socket.data.clientCapabilities = [
...(socket.data.clientCapabilities || []),
CAPABILITIES.CHAT_V1
];
}
room.lastActivity = Date.now();
io.to(mapping.roomId).emit(EVENTS.CHAT_MESSAGE, envelope);
for (const socketId of room.peers) {
const recipient = io.sockets.sockets.get(socketId);
if (clientSupportsChat(recipient)) {
recipient.emit(EVENTS.CHAT_MESSAGE, envelope);
}
}
} catch (err) {
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
}
+90 -51
View File
@@ -30,12 +30,12 @@
}
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/ws": {
@@ -70,20 +70,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -93,6 +93,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -347,9 +360,9 @@
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -526,9 +539,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -558,9 +571,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -604,12 +617,16 @@
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
@@ -739,12 +756,13 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -754,12 +772,16 @@
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
@@ -851,14 +873,14 @@
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -951,9 +973,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@@ -1025,23 +1047,40 @@
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/unpipe": {
@@ -1069,9 +1108,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+6
View File
@@ -178,3 +178,9 @@ export const BLACKLIST_DOMAINS = [
'lichess.org',
'skribbl.io'
];
// Explicit exceptions for useful media pages whose parent domain is filtered.
// Google Drive is otherwise matched indirectly by the broad `google.com` entry.
export const BLACKLIST_ALLOWLIST_DOMAINS = [
'drive.google.com'
];
+3 -2
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "2.6.4";
export const APP_VERSION = "3.0.0";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
@@ -81,7 +81,8 @@ export const CONTROL_MODES = {
export const CAPABILITIES = {
HOST_CONTROL: 'host-control',
CO_HOST: 'co-host', // owner promotes guests to additional controllers
CHAT: 'chat'
CHAT: 'chat', // legacy server capability used by the first chat beta
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
+1 -1
View File
@@ -78,7 +78,7 @@ Compatibility depends on each website's player implementation and can change whe
## Technical information
- Current website release: 2.6.4
- Current website release: 3.0.0
- License: MIT
- Extension runtime: dependency-free browser extension code
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
+1 -1
View File
@@ -116,7 +116,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "2.6.4",
"softwareVersion": "3.0.0",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2.6.4",
"date": "2026-07-16T09:51:25Z"
"version": "3.0.0",
"date": "2026-07-26T00:25:06Z"
}