Compare commits

..

11 Commits

Author SHA1 Message Date
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
35 changed files with 1649 additions and 1374 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 ### Prerequisites
- **Node.js** v18+ - **Node.js** v20.9+
- **Docker** (for local relay server testing) - **Docker** (for local relay server testing)
### Quick Start ### Quick Start
+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 ## [v2.6.4] — 2026-07-16
### Changed ### Changed
+13
View File
@@ -67,6 +67,19 @@ the stamped value as AAD.
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations. - 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 - Without a key, the panel stays closed and a disabled chat control explains that a
current invite link is required. 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. - 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. Peer removal and role management are outside chat scope.
+15 -4
View File
@@ -54,6 +54,7 @@ Payload:
"password": "string, max 128, optional", "password": "string, max 128, optional",
"tabTitle": "string, max 100, optional", "tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional", "mediaTitle": "string, max 100, optional",
"clientCapabilities": ["chat-v1"],
"protocolVersion": "string, max 16" "protocolVersion": "string, max 16"
} }
``` ```
@@ -82,7 +83,7 @@ Payload:
"hostPeerId": "string or null", "hostPeerId": "string or null",
"controlMode": "everyone | host-only", "controlMode": "everyone | host-only",
"controllers": ["peerId"], "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 ## Ephemeral encrypted chat
Relays advertise chat support with `"chat"` in `room_data.capabilities`. Clients Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
must not infer support from another field. 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` ### `chat_message`
@@ -106,7 +116,7 @@ Client to relay:
authentication tag. The relay validates only canonical base64url and byte bounds. authentication tag. The relay validates only canonical base64url and byte bounds.
It cannot inspect plaintext. 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 ```json
{ {
@@ -413,5 +423,6 @@ If sender and target are still in the same room, the relay emits:
- `host-control` - `host-control`
- `co-host` - `co-host`
- `chat` - `chat`
- `chat-v1`
Clients should treat a missing or unknown capabilities list as unsupported. Clients should treat a missing or unknown capabilities list as unsupported.
+77 -11
View File
@@ -30,6 +30,83 @@
*Prioritized for upcoming phases.* *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 ### 2. Invite link with target URL for auto-redirect
- **Priority:** P2 - **Priority:** P2
@@ -49,17 +126,6 @@
*Ideas and feature requests under evaluation.* *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 ### Cross-frame video detection and control
- **Priority:** P3 - **Priority:** P3
+25 -9
View File
@@ -91,6 +91,10 @@ let chatReceiveQueue = Promise.resolve();
const chatSendLimiter = createChatSendLimiter(); const chatSendLimiter = createChatSendLimiter();
const webJoinCoordinator = createLatestTaskQueue(); const webJoinCoordinator = createLatestTaskQueue();
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); } 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() { function invalidateChatSession() {
chatSessionGeneration++; chatSessionGeneration++;
@@ -394,7 +398,7 @@ async function getSettings() {
// (username) must NEVER come from storage.sync — syncing them across devices // (username) must NEVER come from storage.sync — syncing them across devices
// both leaks them and resurrects dead rooms on reinstall (a fresh install // 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). // 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; let username = data.username;
if (!username) { if (!username) {
username = generateUsername(); username = generateUsername();
@@ -412,6 +416,7 @@ async function getSettings() {
roomId, roomId,
password: data.password || '', password: data.password || '',
chatKey, chatKey,
chatEnabled: data.chatEnabled === true,
username, username,
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode), sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
mediaTitlePrivacyMode mediaTitlePrivacyMode
@@ -447,7 +452,8 @@ function emitEpisodeLobbyForCurrentPrivacy() {
// removes legacy keys that older versions wrote to sync (and that would // removes legacy keys that older versions wrote to sync (and that would
// otherwise be redistributed across devices and resurrected on reinstall). // otherwise be redistributed across devices and resurrected on reinstall).
const LEGACY_SYNC_KEYS = [ const LEGACY_SYNC_KEYS = [
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode' 'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
@@ -748,6 +754,7 @@ async function connect() {
peerId, peerId,
username: settings.username, username: settings.username,
tabTitle: sharedTitles.tabTitle, tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION protocolVersion: PROTOCOL_VERSION
}); });
} }
@@ -1193,7 +1200,7 @@ async function handleServerEvent(event, data) {
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {}); chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
break; break;
case EVENTS.CHAT_MESSAGE: { case EVENTS.CHAT_MESSAGE: {
if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break; if (!currentRoom || !serverSupportsChat() || !currentTabId) break;
const generation = chatSessionGeneration; const generation = chatSessionGeneration;
const roomId = currentRoom.roomId; const roomId = currentRoom.roomId;
const tabId = Number(currentTabId); const tabId = Number(currentTabId);
@@ -1203,7 +1210,7 @@ async function handleServerEvent(event, data) {
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => { chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
if (!isCurrentSession()) return; if (!isCurrentSession()) return;
const settings = await getSettings(); 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; const chatKey = settings.chatKey;
try { try {
const text = await decryptChatMessage({ const text = await decryptChatMessage({
@@ -2547,7 +2554,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}); });
chrome.storage.onChanged.addListener((changes, area) => { 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); if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
invalidateChatSession(); invalidateChatSession();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
@@ -2593,6 +2600,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId, peerId,
username: settings.username, username: settings.username,
tabTitle: sharedTitles.tabTitle, tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION protocolVersion: PROTOCOL_VERSION
}); });
} }
@@ -2611,6 +2619,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await retryPendingTarget(); await retryPendingTarget();
} }
const pendingTarget = await readPendingTarget(); const pendingTarget = await readPendingTarget();
const settings = await getSettings();
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0; const isReconnecting = !isConnected && reconnectAttempts > 0;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected')); let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
@@ -2641,8 +2650,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
amController: amController(), amController: amController(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
coHostSupported: serverSupports(CAPABILITIES.CO_HOST), coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
chatSupported: serverSupports(CAPABILITIES.CHAT), chatSupported: serverSupportsChat(),
hasChatKey: !!(await getSettings()).chatKey hasChatKey: !!settings.chatKey,
chatEnabled: settings.chatEnabled
}); });
} else if (message.type === 'GET_CHAT_CONTEXT') { } else if (message.type === 'GET_CHAT_CONTEXT') {
const senderTabId = sender.tab?.id; const senderTabId = sender.tab?.id;
@@ -2658,7 +2668,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return value === key ? '' : value; return value === key ? '' : value;
}; };
sendResponse({ sendResponse({
supported: serverSupports(CAPABILITIES.CHAT), supported: serverSupportsChat(),
enabled: settings.chatEnabled,
hasKey: !!settings.chatKey, hasKey: !!settings.chatKey,
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined), connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
peerId, peerId,
@@ -2686,7 +2697,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'invalid_tab' }); sendResponse({ status: 'invalid_tab' });
return; return;
} }
if (!serverSupports(CAPABILITIES.CHAT)) { if (!serverSupportsChat()) {
sendResponse({ status: 'unsupported' }); sendResponse({ status: 'unsupported' });
return; return;
} }
@@ -2702,6 +2713,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom?.roomId === roomId && Number(currentTabId) === tabId && currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined; socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
const settings = await getSettings(); const settings = await getSettings();
if (!settings.chatEnabled) {
sendResponse({ status: 'disabled' });
return;
}
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) { if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' }); sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
return; return;
@@ -2945,6 +2960,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId, peerId,
username: settings.username, username: settings.username,
tabTitle: sharedTitles.tabTitle, tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION protocolVersion: PROTOCOL_VERSION
}); });
} }
+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 popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const localeDir = path.join(extensionDir, 'locales'); const localeDir = path.join(extensionDir, 'locales');
const chatKeys = [ const chatKeys = [
'LABEL_CHAT_ENABLED',
'LABEL_CHAT_ENABLED_TOOLTIP',
'CHAT_TITLE', 'CHAT_TITLE',
'CHAT_LIVE_ONLY', 'CHAT_LIVE_ONLY',
'CHAT_OPEN', 'CHAT_OPEN',
@@ -60,6 +62,18 @@ describe('chat overlay contract', () => {
expect(backgroundSource).toContain("status: 'rate_limited'"); 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', () => { it('keeps unavailable chat controls discoverable to assistive technology', () => {
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'"); expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
expect(overlaySource).not.toContain('launcher.disabled ='); expect(overlaySource).not.toContain('launcher.disabled =');
+110 -16
View File
@@ -9,6 +9,11 @@
const MIN_HEIGHT = 320; const MIN_HEIGHT = 320;
const DEFAULT_WIDTH = 360; const DEFAULT_WIDTH = 360;
const DEFAULT_HEIGHT = 520; 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 storageKey = `chatOverlayLayout:${location.origin}`;
const systemTheme = window.matchMedia('(prefers-color-scheme: light)'); const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let context = null; let context = null;
@@ -16,16 +21,30 @@
let destroyed = false; let destroyed = false;
let saveTimer = null; let saveTimer = null;
let applyingLayout = false; let applyingLayout = false;
let preferencesLoaded = false;
let startStateApplied = false;
let refreshGeneration = 0; let refreshGeneration = 0;
let sendGeneration = 0; let sendGeneration = 0;
let sending = false; 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 themeMode = 'system';
let themePalette = 'eucalyptus'; let themePalette = 'eucalyptus';
const host = document.createElement('div'); const host = document.createElement('div');
host.id = 'koalasync-chat-overlay-host'; 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 shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
@@ -243,6 +262,21 @@
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) }; 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() { function clampDetached() {
const viewport = viewportBounds(); const viewport = viewportBounds();
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16)); const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
@@ -265,6 +299,7 @@
function applyLayout() { function applyLayout() {
applyingLayout = true; applyingLayout = true;
const size = preferredSize();
panel.style.right = 'auto'; panel.style.right = 'auto';
panel.style.left = 'auto'; panel.style.left = 'auto';
panel.style.bottom = 'auto'; panel.style.bottom = 'auto';
@@ -288,8 +323,8 @@
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2))); 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; const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
panel.style.top = `${panelTop}px`; panel.style.top = `${panelTop}px`;
panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`; panel.style.width = `${Math.max(1, Math.min(size.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.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`; panel.style[layout.mode] = `${gutter}px`;
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}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'; launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
@@ -298,16 +333,35 @@
globalThis.queueMicrotask(() => { applyingLayout = false; }); globalThis.queueMicrotask(() => { applyingLayout = false; });
} }
function setMode(mode) { function setMode(mode, persistPreference = true) {
if (!['left', 'right', 'detached'].includes(mode)) return; mode = normalizePosition(mode);
if (mode === 'detached' && !layout.detachedInitialized) { if (mode === 'detached' && !layout.detachedInitialized) {
layout.x = Math.max(8, Math.round((window.innerWidth - DEFAULT_WIDTH) / 2)); const size = preferredSize();
layout.y = Math.max(8, Math.round((window.innerHeight - DEFAULT_HEIGHT) / 2)); 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; layout.detachedInitialized = true;
} }
chatPosition = mode;
layout.mode = mode; layout.mode = mode;
applyLayout(); applyLayout();
saveLayout(); 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) { function setOpened(next) {
@@ -324,13 +378,23 @@
const previousRoomId = context?.roomId; const previousRoomId = context?.roomId;
context = next || null; context = next || null;
const supported = !!context?.supported; const supported = !!context?.supported;
const optedIn = !!context?.enabled;
const hasKey = !!context?.hasKey; const hasKey = !!context?.hasKey;
const connected = !!context?.connected; const connected = !!context?.connected;
context = context ? { ...context, enabled: supported && hasKey && connected } : null; context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages(); if (previousRoomId && previousRoomId !== context?.roomId) {
host.style.display = supported ? '' : 'none'; clearMessages();
startStateApplied = false;
}
host.style.display = supported && optedIn ? '' : 'none';
launcher.setAttribute('aria-disabled', String(!context?.enabled)); 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(); applyStrings();
applyLayout(); applyLayout();
} }
@@ -450,8 +514,15 @@
if (applyingLayout || layout.mode !== 'detached') return; if (applyingLayout || layout.mode !== 'detached') return;
const rect = panel.getBoundingClientRect(); const rect = panel.getBoundingClientRect();
if (!opened || !rect?.width || !rect.height) return; 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.width = rect.width;
layout.height = rect.height; layout.height = rect.height;
layout.customWidth = rect.width;
layout.customHeight = rect.height;
if (chatSize !== 'custom') {
chatSize = 'custom';
chrome.storage.local.set({ chatSize }).catch(() => {});
}
clampDetached(); clampDetached();
saveLayout(); saveLayout();
}); });
@@ -480,6 +551,15 @@
if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system'; if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system';
if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus'; if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus';
if (changes.themeMode || changes.themePalette) applyTheme(); 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(); if (changes.locale) refresh();
} }
@@ -515,16 +595,30 @@
systemTheme.addEventListener('change', handleSystemTheme); systemTheme.addEventListener('change', handleSystemTheme);
chrome.storage.onChanged.addListener(handleStorage); chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime); chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette'], data => { chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode'], data => {
if (data[storageKey] && typeof data[storageKey] === 'object') layout = { ...layout, ...data[storageKey] }; const storedLayout = data[storageKey];
if (!['left', 'right', 'detached'].includes(layout.mode)) layout.mode = 'right'; 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; 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'; themeMode = data.themeMode || 'system';
themePalette = data.themePalette || 'eucalyptus'; themePalette = data.themePalette || 'eucalyptus';
preferencesLoaded = true;
applyTheme(); applyTheme();
applyLayout(); applyLayout();
refresh();
}); });
window.koalaSyncChatOverlay = { refresh, destroy }; window.koalaSyncChatOverlay = { refresh, destroy };
refresh();
})(); })();
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.",
"LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an.", "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_TITLE": "Raum-Chat",
"CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.", "CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.",
"CHAT_OPEN": "Raum-Chat öffnen", "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_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.",
"LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences.", "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_TITLE": "Room Chat",
"CHAT_LIVE_ONLY": "Live only. No history.", "CHAT_LIVE_ONLY": "Live only. No history.",
"CHAT_OPEN": "Open room chat", "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_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": "Reproducción y Sincronización",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio.", "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_TITLE": "Chat de la sala",
"CHAT_LIVE_ONLY": "Solo en directo. Sin historial.", "CHAT_LIVE_ONLY": "Solo en directo. Sin historial.",
"CHAT_OPEN": "Abrir chat de la sala", "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_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": "Lecture & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio.", "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_TITLE": "Chat du salon",
"CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.", "CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.",
"CHAT_OPEN": "Ouvrir le chat du salon", "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_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.",
"LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio.", "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_TITLE": "Chat della stanza",
"CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.", "CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
"CHAT_OPEN": "Apri la chat della stanza", "CHAT_OPEN": "Apri la chat della stanza",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期", "LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。", "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_TITLE": "ルームチャット",
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。", "CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
"CHAT_OPEN": "ルームチャットを開く", "CHAT_OPEN": "ルームチャットを開く",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화", "LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.", "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_TITLE": "방 채팅",
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.", "CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
"CHAT_OPEN": "방 채팅 열기", "CHAT_OPEN": "방 채팅 열기",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.",
"LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan.", "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_TITLE": "Kamerchat",
"CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.", "CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
"CHAT_OPEN": "Kamerchat openen", "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_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.",
"LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync", "LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio.", "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_TITLE": "Czat pokoju",
"CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.", "CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.",
"CHAT_OPEN": "Otwórz czat pokoju", "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_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": "Reprodução e Sincronização",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.", "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_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.", "CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala", "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_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": "Reprodução e Sincronização",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.", "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_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.", "CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala", "CHAT_OPEN": "Abrir chat da sala",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация", "LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.", "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_TITLE": "Чат комнаты",
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.", "CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
"CHAT_OPEN": "Открыть чат комнаты", "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_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": "Oynatma ve Eşitleme",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin.", "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_TITLE": "Oda Sohbeti",
"CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.", "CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
"CHAT_OPEN": "Oda sohbetini aç", "CHAT_OPEN": "Oda sohbetini aç",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація", "LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.", "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_TITLE": "Чат кімнати",
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.", "CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
"CHAT_OPEN": "Відкрити чат кімнати", "CHAT_OPEN": "Відкрити чат кімнати",
+16
View File
@@ -246,6 +246,22 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步", "LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。", "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_TITLE": "房间聊天",
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。", "CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
"CHAT_OPEN": "打开房间聊天", "CHAT_OPEN": "打开房间聊天",
+50
View File
@@ -1215,6 +1215,12 @@
line-height: 1.45; line-height: 1.45;
text-align: left; text-align: left;
} }
[data-chat-setting] {
transition: opacity 0.2s;
}
[data-chat-setting].is-disabled {
opacity: 0.5;
}
.settings-issue-link { .settings-issue-link {
margin: 8px 0 2px; margin: 8px 0 2px;
padding-top: 9px; padding-top: 9px;
@@ -1615,6 +1621,50 @@
</div> </div>
</details> </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"> <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> <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"> <div class="details-content">
+45 -1
View File
@@ -68,6 +68,10 @@ const elements = {
playBtn: document.getElementById('playBtn'), playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn'), pauseBtn: document.getElementById('pauseBtn'),
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'), autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
chatEnabled: document.getElementById('chatEnabled'),
chatPosition: document.getElementById('chatPosition'),
chatSize: document.getElementById('chatSize'),
chatStartMode: document.getElementById('chatStartMode'),
sendTabTitle: document.getElementById('sendTabTitle'), sendTabTitle: document.getElementById('sendTabTitle'),
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'), mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'), episodeLobbyCard: document.getElementById('episodeLobbyCard'),
@@ -337,7 +341,7 @@ function setRoomRefreshCooldown() {
async function init() { async function init() {
// Local-only by design — settings and room credentials never come from // Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there). // 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; let activeLang = localData.locale;
if (!activeLang) { if (!activeLang) {
@@ -368,6 +372,11 @@ async function init() {
syncDevToolsVisibility(); syncDevToolsVisibility();
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false; if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== 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 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; const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode); if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
@@ -1456,6 +1465,41 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked }); 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) { if (elements.sendTabTitle) {
elements.sendTabTitle.addEventListener('change', () => { elements.sendTabTitle.addEventListener('change', () => {
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => { chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
+783 -1254
View File
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -4,6 +4,9 @@
"description": "KoalaSync Build Scripts", "description": "KoalaSync Build Scripts",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": {
"node": ">=20.9.0"
},
"scripts": { "scripts": {
"build:extension": "node scripts/build-extension.cjs", "build:extension": "node scripts/build-extension.cjs",
"indexnow": "node website/submit-indexnow.cjs", "indexnow": "node website/submit-indexnow.cjs",
@@ -15,17 +18,22 @@
"verify": "node scripts/verify-release.mjs" "verify": "node scripts/verify-release.mjs"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1", "@playwright/test": "^1.62.0",
"@vitest/coverage-v8": "^4.1.9", "@vitest/coverage-v8": "^4.1.10",
"addons-linter": "^10.9.0", "addons-linter": "^10.9.0",
"archiver": "^7.0.1", "archiver": "^8.0.0",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"eslint": "^10.4.0", "eslint": "^10.8.0",
"eslint-plugin-no-unsanitized": "^4.1.5", "eslint-plugin-no-unsanitized": "^4.1.5",
"fs-extra": "^11.2.0", "fs-extra": "^11.4.0",
"htmlnano": "^3.4.0", "htmlnano": "^3.4.0",
"sharp": "^0.34.5", "sharp": "^0.35.3",
"svgo": "^4.0.1", "svgo": "^4.0.2",
"vitest": "^4.1.9" "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 fs = require('fs');
const path = require('path'); const path = require('path');
const archiver = require('archiver');
const rootDir = path.join(__dirname, '..'); const rootDir = path.join(__dirname, '..');
const extDir = path.join(rootDir, 'extension'); const extDir = path.join(rootDir, 'extension');
@@ -202,18 +201,18 @@ function copyExtensionFiles(targetDir, browserName) {
} }
// Helper to zip a directory // Helper to zip a directory
function zipDirectory(sourceDir, outPath) { async function zipDirectory(sourceDir, outPath) {
const { ZipArchive } = await import('archiver');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } }); const archive = new ZipArchive({ zlib: { level: 9 } });
const stream = fs.createWriteStream(outPath); const stream = fs.createWriteStream(outPath);
archive archive.on('error', reject);
.directory(sourceDir, false)
.on('error', err => reject(err))
.pipe(stream);
stream.on('close', () => resolve()); 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');
+37 -2
View File
@@ -23,7 +23,10 @@ async function c() {
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); } 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);}); } 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 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; } 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 // 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). // 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'), assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
'ROOM_DATA advertises the host-control capability'); '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'), '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'); assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
close(); close();
resetConnectionRate(); resetConnectionRate();
@@ -79,7 +83,8 @@ try {
// --- Encrypted chat is a live-only canonical relay --- // --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now(); const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c(); 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; chat1._m.length = chat2._m.length = 0;
const ciphertext = Buffer.alloc(64, 7).toString('base64url'); const ciphertext = Buffer.alloc(64, 7).toString('base64url');
s(chat1, 'chat_message', { s(chat1, 'chat_message', {
@@ -106,6 +111,36 @@ try {
close(); close();
resetConnectionRate(); 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) --- // --- 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 // Confirms that with host-only off, a non-host guest can still drive every
// room-moving event exactly like before the feature existed. // 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']], ['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']], ['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.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']], ['host access recovery', 'node', ['scripts/test-host-access.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']], ['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.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 WORKDIR /app
# Copy shared protocol first # Copy shared protocol first
COPY shared/ ./shared/ COPY --chown=node:node shared/ ./shared/
# Copy server # Copy server
COPY server/package*.json ./server/ COPY --chown=node:node server/package*.json ./server/
RUN cd server && npm install --production RUN cd server && npm ci --omit=dev
COPY server/ ./server/ COPY --chown=node:node server/ ./server/
WORKDIR /app/server WORKDIR /app/server
USER node
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 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 // 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 // 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). // 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 // 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 // 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 username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : 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 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 if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
@@ -370,6 +390,7 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining // Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id); const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) { if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
socket.data.clientCapabilities = clientCapabilities;
return; // Already in this room with same peerId, ignore to prevent spam return; // Already in this room with same peerId, ignore to prevent spam
} }
if (oldMapping && oldMapping.roomId !== roomId) { if (oldMapping && oldMapping.roomId !== roomId) {
@@ -493,6 +514,7 @@ io.on('connection', (socket) => {
} }
socket.join(roomId); socket.join(roomId);
socket.data.clientCapabilities = clientCapabilities;
room.peers.add(socket.id); room.peers.add(socket.id);
room.peerIds.set(socket.id, peerId); room.peerIds.set(socket.id, peerId);
room.peerData.set(socket.id, { room.peerData.set(socket.id, {
@@ -862,8 +884,22 @@ io.on('connection', (socket) => {
const envelope = createChatEnvelope(data, mapping.peerId); const envelope = createChatEnvelope(data, mapping.peerId);
if (!envelope) return; 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(); 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) { } catch (err) {
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`); log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
} }
+90 -51
View File
@@ -30,12 +30,12 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.6.0", "version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.19.0" "undici-types": "~8.3.0"
} }
}, },
"node_modules/@types/ws": { "node_modules/@types/ws": {
@@ -70,20 +70,20 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^2.0.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.1", "qs": "^6.15.2",
"raw-body": "^3.0.1", "raw-body": "^3.0.2",
"type-is": "^2.0.1" "type-is": "^2.1.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -93,6 +93,19 @@
"url": "https://opencollective.com/express" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -347,9 +360,9 @@
} }
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -526,9 +539,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.3", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -558,9 +571,9 @@
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.2", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -604,12 +617,16 @@
} }
}, },
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/merge-descriptors": { "node_modules/merge-descriptors": {
@@ -739,12 +756,13 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.2", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@@ -754,12 +772,16 @@
} }
}, },
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/raw-body": { "node_modules/raw-body": {
@@ -851,14 +873,14 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
@@ -951,9 +973,9 @@
} }
}, },
"node_modules/socket.io-parser": { "node_modules/socket.io-parser": {
"version": "4.2.6", "version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@socket.io/component-emitter": "~3.1.0", "@socket.io/component-emitter": "~3.1.0",
@@ -1025,23 +1047,40 @@
} }
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^2.0.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"mime-types": "^3.0.0" "mime-types": "^3.0.0"
}, },
"engines": { "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": { "node_modules/undici-types": {
"version": "7.19.2", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
@@ -1069,9 +1108,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.21.0", "version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
+2 -1
View File
@@ -81,7 +81,8 @@ export const CONTROL_MODES = {
export const CAPABILITIES = { export const CAPABILITIES = {
HOST_CONTROL: 'host-control', HOST_CONTROL: 'host-control',
CO_HOST: 'co-host', // owner promotes guests to additional controllers 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 export const HEARTBEAT_INTERVAL = 15000; // 15s