mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0954079a1 | |||
| 9251ae6aff | |||
| 511e042b0b | |||
| 372c384a19 | |||
| 79715046a8 | |||
| 2ae79386a0 | |||
| 883e49433e | |||
| 59135b319e | |||
| ae2238e3bc | |||
| 77790a279c | |||
| 2df048621b |
+1
-1
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** v18+
|
||||
- **Node.js** v20.9+
|
||||
- **Docker** (for local relay server testing)
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -4,6 +4,22 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v3.0.0] — 2026-07-26
|
||||
|
||||
### Added
|
||||
- **Extension: Optional encrypted room chat** — Rooms now support live-only, end-to-end encrypted text messages. Chat is disabled and hidden by default and can be enabled explicitly in the extension options.
|
||||
- **Extension: Persistent room chat key** — Every room generates and retains a chat key even while chat is disabled, so chat can be enabled later without creating a new room.
|
||||
- **Extension: Floating player chat** — When enabled, chat is available from a floating control over the selected player and opens in an overlay without replacing the synchronized video.
|
||||
- **Extension: Dedicated chat settings** — Chat now has its own settings section for enablement, left/right/free-floating placement, compact/standard/large/custom sizing, and bubble-versus-open startup behavior.
|
||||
|
||||
### Security
|
||||
- **Ciphertext-only relay** — The relay receives and forwards encrypted message payloads without storing chat history or accepting client-supplied plaintext, sender identities, timestamps, or message IDs as authoritative.
|
||||
- **Backward-compatible rollout** — Versioned `chat-v1` capabilities ensure old non-chat extensions never receive unknown chat events, while current extensions continue to work with older chatless relay versions.
|
||||
|
||||
### Changed
|
||||
- **Build dependencies** — Updated the supported build and validation toolchain and refreshed compatible transitive dependencies.
|
||||
- **Relay container runtime** — Moved the production container from end-of-life Node.js 20 to Node.js 24 LTS and made production dependency installation deterministic with `npm ci --omit=dev`.
|
||||
|
||||
## [v2.6.4] — 2026-07-16
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -67,6 +67,19 @@ the stamped value as AAD.
|
||||
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations.
|
||||
- Without a key, the panel stays closed and a disabled chat control explains that a
|
||||
current invite link is required.
|
||||
- Chat display is a local option and defaults to off. Enabling or disabling it never
|
||||
deletes the room chat secret, so it can be enabled later without creating a room.
|
||||
- Without the relay `chat` capability, no chat control is shown.
|
||||
|
||||
## Mixed-version rollout
|
||||
|
||||
- New extensions announce `chat-v1` in `join_room.clientCapabilities`.
|
||||
- Old non-chat extensions omit the optional field and receive no `chat_message`
|
||||
events. Their playback and room protocol remains unchanged.
|
||||
- The first chat beta omitted the capability. When it sends one valid v1 chat
|
||||
frame, the relay treats that socket as chat-capable for the rest of the
|
||||
connection.
|
||||
- New extensions accept the first beta's unversioned server `chat` capability as
|
||||
well as `chat-v1`, so a server-first or extension-first rollout degrades safely.
|
||||
|
||||
Peer removal and role management are outside chat scope.
|
||||
|
||||
+15
-4
@@ -54,6 +54,7 @@ Payload:
|
||||
"password": "string, max 128, optional",
|
||||
"tabTitle": "string, max 100, optional",
|
||||
"mediaTitle": "string, max 100, optional",
|
||||
"clientCapabilities": ["chat-v1"],
|
||||
"protocolVersion": "string, max 16"
|
||||
}
|
||||
```
|
||||
@@ -82,7 +83,7 @@ Payload:
|
||||
"hostPeerId": "string or null",
|
||||
"controlMode": "everyone | host-only",
|
||||
"controllers": ["peerId"],
|
||||
"capabilities": ["host-control", "co-host", "chat"]
|
||||
"capabilities": ["host-control", "co-host", "chat", "chat-v1"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -91,8 +92,17 @@ for every later room update.
|
||||
|
||||
## Ephemeral encrypted chat
|
||||
|
||||
Relays advertise chat support with `"chat"` in `room_data.capabilities`. Clients
|
||||
must not infer support from another field.
|
||||
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
|
||||
the initial beta's `"chat"` flag during the transition. New clients announce
|
||||
`"chat-v1"` in optional `join_room.clientCapabilities` (at most the first 16 entries
|
||||
are inspected; unknown or malformed values are ignored). Old clients omit the field
|
||||
and continue using the pre-chat protocol unchanged.
|
||||
|
||||
The relay sends `chat_message` only to sockets that announced `"chat-v1"`. As a
|
||||
transition for the first chat beta, a socket that sends a valid v1 ciphertext is
|
||||
marked capable for the rest of that connection. This prevents old non-chat
|
||||
extensions from receiving unknown events while preserving the first beta's send
|
||||
path.
|
||||
|
||||
### `chat_message`
|
||||
|
||||
@@ -106,7 +116,7 @@ Client to relay:
|
||||
authentication tag. The relay validates only canonical base64url and byte bounds.
|
||||
It cannot inspect plaintext.
|
||||
|
||||
Relay to every current room peer, including the sender:
|
||||
Relay to every chat-capable current room peer, including the sender:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -413,5 +423,6 @@ If sender and target are still in the same room, the relay emits:
|
||||
- `host-control`
|
||||
- `co-host`
|
||||
- `chat`
|
||||
- `chat-v1`
|
||||
|
||||
Clients should treat a missing or unknown capabilities list as unsupported.
|
||||
|
||||
+77
-11
@@ -30,6 +30,83 @@
|
||||
|
||||
*Prioritized for upcoming phases.*
|
||||
|
||||
### v3.0 — Modern Room Chat
|
||||
|
||||
- **Release date:** Not scheduled
|
||||
- **Priority:** P1
|
||||
- **Category:** Social / Communication / Privacy
|
||||
- **Status:** Planned. This is a roadmap target, not a release announcement.
|
||||
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
|
||||
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
|
||||
The relay stores no chat history and mixed extension versions use capability-gated
|
||||
delivery.
|
||||
|
||||
#### Core experience
|
||||
|
||||
- Floating bubble with an accessible unread badge (`1`, `2`, `3`, …, `99+`), cleared
|
||||
only when the chat is actually viewed.
|
||||
- Subtle new-message pulse with reduced-motion support; no animation or sound while
|
||||
muted or during Do Not Disturb.
|
||||
- Drag-and-snap bubble positioning, remembered per site, with reset-to-default.
|
||||
- Dock left/right, detached overlay, compact mode, fullscreen-safe placement, responsive
|
||||
narrow-player layout, and keyboard-only operation.
|
||||
- Per-room mute, mention-only mode, notification sound controls, browser notifications,
|
||||
and a visible mute state.
|
||||
- Unread state survives overlay close/reopen and tab focus changes, but room messages
|
||||
remain memory-only unless a future storage option is explicitly enabled.
|
||||
|
||||
#### Messaging
|
||||
|
||||
- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing
|
||||
indicators.
|
||||
- Edit and delete own messages with clear local tombstones; no silent mutation.
|
||||
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
|
||||
duplicate suppression after reconnects.
|
||||
- Message timestamps, date separators, jump-to-latest, unread divider, search within
|
||||
the current live session, copy, and selectable text.
|
||||
- Link detection with safe external opening, explicit scheme allowlist, and no automatic
|
||||
previews or remote-media loading.
|
||||
- Optional image/GIF/file sharing only after encrypted size limits, malware-risk UX,
|
||||
relay bandwidth limits, and privacy behavior are designed and tested.
|
||||
|
||||
#### Presence, safety, and control
|
||||
|
||||
- Online/reconnecting status, join/leave events, and room-member presence without
|
||||
exposing IP addresses or cross-room identity.
|
||||
- Local mute/block, host moderation controls, slow mode, spam limits, and configurable
|
||||
maximum message length.
|
||||
- Optional read receipts and activity indicators default off; never infer or expose
|
||||
viewing behavior without explicit consent.
|
||||
- Clear live-only/no-history explanation, encrypted-chat indicator, room-key fingerprint,
|
||||
and key-rotation/rejoin UX.
|
||||
|
||||
#### Reliability and compatibility
|
||||
|
||||
- Versioned chat capabilities for every new wire feature; unknown fields/events remain
|
||||
harmless to older extensions and old non-chat clients receive no chat traffic.
|
||||
- Server-first and extension-first rollout tests, including current, first-chat-beta,
|
||||
old non-chat, reconnect, duplicate-frame, and malformed-capability clients.
|
||||
- Bounded queues, payloads, unread counters, typing events, reactions, and attachment
|
||||
metadata; backpressure and rate limits on both client and relay.
|
||||
- Real Chrome and Firefox extension E2E coverage for bubble, unread count, overlay,
|
||||
fullscreen, reconnect, and mixed-version rooms before v3.0 can ship.
|
||||
|
||||
#### Accessibility and localization
|
||||
|
||||
- Screen-reader announcements that do not read every busy-room message by default.
|
||||
- Complete focus management, logical tab order, Escape behavior, high contrast,
|
||||
zoom/reflow, reduced motion, and touch target coverage.
|
||||
- All chat UI, notification text, moderation states, and errors localized across every
|
||||
supported locale before release.
|
||||
|
||||
#### Explicit non-goals for the first v3.0 release
|
||||
|
||||
- No server-readable plaintext.
|
||||
- No mandatory accounts, public room directory, advertising, tracking, or presence
|
||||
across rooms.
|
||||
- No unencrypted server-side history. Any future encrypted history requires a separate
|
||||
threat model, retention controls, export/delete behavior, and explicit opt-in.
|
||||
|
||||
### 2. Invite link with target URL for auto-redirect
|
||||
|
||||
- **Priority:** P2
|
||||
@@ -49,17 +126,6 @@
|
||||
|
||||
*Ideas and feature requests under evaluation.*
|
||||
|
||||
### In-room chat overlay (like TeleParty)
|
||||
|
||||
- **Priority:** P3
|
||||
- **Category:** Social / Communication
|
||||
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
|
||||
- **Why backlog (still uncertain):**
|
||||
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
|
||||
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
|
||||
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
|
||||
- **Status:** Under evaluation, may come later.
|
||||
|
||||
### Cross-frame video detection and control
|
||||
|
||||
- **Priority:** P3
|
||||
|
||||
+25
-9
@@ -91,6 +91,10 @@ let chatReceiveQueue = Promise.resolve();
|
||||
const chatSendLimiter = createChatSendLimiter();
|
||||
const webJoinCoordinator = createLatestTaskQueue();
|
||||
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
|
||||
function serverSupportsChat() {
|
||||
return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT);
|
||||
}
|
||||
const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]);
|
||||
|
||||
function invalidateChatSession() {
|
||||
chatSessionGeneration++;
|
||||
@@ -394,7 +398,7 @@ async function getSettings() {
|
||||
// (username) must NEVER come from storage.sync — syncing them across devices
|
||||
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
||||
// has empty local storage but sync survives in the user's Google account).
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
username = generateUsername();
|
||||
@@ -412,6 +416,7 @@ async function getSettings() {
|
||||
roomId,
|
||||
password: data.password || '',
|
||||
chatKey,
|
||||
chatEnabled: data.chatEnabled === true,
|
||||
username,
|
||||
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
||||
mediaTitlePrivacyMode
|
||||
@@ -447,7 +452,8 @@ function emitEpisodeLobbyForCurrentPrivacy() {
|
||||
// removes legacy keys that older versions wrote to sync (and that would
|
||||
// otherwise be redistributed across devices and resurrected on reinstall).
|
||||
const LEGACY_SYNC_KEYS = [
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username',
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
|
||||
'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
|
||||
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
||||
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
||||
@@ -748,6 +754,7 @@ async function connect() {
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
clientCapabilities: CLIENT_CAPABILITIES,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
@@ -1193,7 +1200,7 @@ async function handleServerEvent(event, data) {
|
||||
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
||||
break;
|
||||
case EVENTS.CHAT_MESSAGE: {
|
||||
if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break;
|
||||
if (!currentRoom || !serverSupportsChat() || !currentTabId) break;
|
||||
const generation = chatSessionGeneration;
|
||||
const roomId = currentRoom.roomId;
|
||||
const tabId = Number(currentTabId);
|
||||
@@ -1203,7 +1210,7 @@ async function handleServerEvent(event, data) {
|
||||
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
|
||||
if (!isCurrentSession()) return;
|
||||
const settings = await getSettings();
|
||||
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
|
||||
if (!settings.chatEnabled || !settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
|
||||
const chatKey = settings.chatKey;
|
||||
try {
|
||||
const text = await decryptChatMessage({
|
||||
@@ -2547,7 +2554,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local' || (!changes.roomId && !changes.chatKey)) return;
|
||||
if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return;
|
||||
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
|
||||
invalidateChatSession();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
@@ -2593,6 +2600,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
clientCapabilities: CLIENT_CAPABILITIES,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
@@ -2611,6 +2619,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
await retryPendingTarget();
|
||||
}
|
||||
const pendingTarget = await readPendingTarget();
|
||||
const settings = await getSettings();
|
||||
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
const isReconnecting = !isConnected && reconnectAttempts > 0;
|
||||
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
||||
@@ -2641,8 +2650,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
amController: amController(),
|
||||
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
|
||||
coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
|
||||
chatSupported: serverSupports(CAPABILITIES.CHAT),
|
||||
hasChatKey: !!(await getSettings()).chatKey
|
||||
chatSupported: serverSupportsChat(),
|
||||
hasChatKey: !!settings.chatKey,
|
||||
chatEnabled: settings.chatEnabled
|
||||
});
|
||||
} else if (message.type === 'GET_CHAT_CONTEXT') {
|
||||
const senderTabId = sender.tab?.id;
|
||||
@@ -2658,7 +2668,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return value === key ? '' : value;
|
||||
};
|
||||
sendResponse({
|
||||
supported: serverSupports(CAPABILITIES.CHAT),
|
||||
supported: serverSupportsChat(),
|
||||
enabled: settings.chatEnabled,
|
||||
hasKey: !!settings.chatKey,
|
||||
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
||||
peerId,
|
||||
@@ -2686,7 +2697,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'invalid_tab' });
|
||||
return;
|
||||
}
|
||||
if (!serverSupports(CAPABILITIES.CHAT)) {
|
||||
if (!serverSupportsChat()) {
|
||||
sendResponse({ status: 'unsupported' });
|
||||
return;
|
||||
}
|
||||
@@ -2702,6 +2713,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
|
||||
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
const settings = await getSettings();
|
||||
if (!settings.chatEnabled) {
|
||||
sendResponse({ status: 'disabled' });
|
||||
return;
|
||||
}
|
||||
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
|
||||
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
|
||||
return;
|
||||
@@ -2945,6 +2960,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
clientCapabilities: CLIENT_CAPABILITIES,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'
|
||||
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
|
||||
const localeDir = path.join(extensionDir, 'locales');
|
||||
const chatKeys = [
|
||||
'LABEL_CHAT_ENABLED',
|
||||
'LABEL_CHAT_ENABLED_TOOLTIP',
|
||||
'CHAT_TITLE',
|
||||
'CHAT_LIVE_ONLY',
|
||||
'CHAT_OPEN',
|
||||
@@ -60,6 +62,18 @@ describe('chat overlay contract', () => {
|
||||
expect(backgroundSource).toContain("status: 'rate_limited'");
|
||||
});
|
||||
|
||||
it('keeps chat hidden by default without discarding the room chat key', () => {
|
||||
expect(popupSource).toContain('localData.chatEnabled === true');
|
||||
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
|
||||
expect(backgroundSource).toContain('clientCapabilities: CLIENT_CAPABILITIES');
|
||||
expect(overlaySource).toContain('all:initial;display:none;position:fixed');
|
||||
expect(overlaySource).toContain("host.style.display = supported && optedIn ? '' : 'none'");
|
||||
expect(overlaySource).toContain('supported && optedIn && hasKey && connected');
|
||||
expect(popupSource).toContain("chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked })");
|
||||
expect(popupSource).toMatch(/if \(isCreating\) \{[\s\S]*?type: 'CREATE_CHAT_KEY'[\s\S]*?chatKey = normalizeChatKey/);
|
||||
expect(popupSource).not.toMatch(/chatEnabled[\s\S]{0,120}chatKey:\s*''/);
|
||||
});
|
||||
|
||||
it('keeps unavailable chat controls discoverable to assistive technology', () => {
|
||||
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
|
||||
expect(overlaySource).not.toContain('launcher.disabled =');
|
||||
|
||||
+110
-16
@@ -9,6 +9,11 @@
|
||||
const MIN_HEIGHT = 320;
|
||||
const DEFAULT_WIDTH = 360;
|
||||
const DEFAULT_HEIGHT = 520;
|
||||
const SIZE_PRESETS = Object.freeze({
|
||||
compact: Object.freeze({ width: 320, height: 400 }),
|
||||
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
|
||||
large: Object.freeze({ width: 440, height: 640 })
|
||||
});
|
||||
const storageKey = `chatOverlayLayout:${location.origin}`;
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
let context = null;
|
||||
@@ -16,16 +21,30 @@
|
||||
let destroyed = false;
|
||||
let saveTimer = null;
|
||||
let applyingLayout = false;
|
||||
let preferencesLoaded = false;
|
||||
let startStateApplied = false;
|
||||
let refreshGeneration = 0;
|
||||
let sendGeneration = 0;
|
||||
let sending = false;
|
||||
let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false };
|
||||
let layout = {
|
||||
mode: 'right',
|
||||
x: 24,
|
||||
y: 72,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
customWidth: DEFAULT_WIDTH,
|
||||
customHeight: DEFAULT_HEIGHT,
|
||||
detachedInitialized: false
|
||||
};
|
||||
let chatPosition = 'right';
|
||||
let chatSize = 'standard';
|
||||
let chatStartMode = 'bubble';
|
||||
let themeMode = 'system';
|
||||
let themePalette = 'eucalyptus';
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.id = 'koalasync-chat-overlay-host';
|
||||
host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
|
||||
host.style.cssText = 'all:initial;display:none;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@@ -243,6 +262,21 @@
|
||||
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
|
||||
}
|
||||
|
||||
function normalizePosition(value) {
|
||||
return ['left', 'right', 'detached'].includes(value) ? value : 'right';
|
||||
}
|
||||
|
||||
function normalizeSize(value) {
|
||||
return ['compact', 'standard', 'large', 'custom'].includes(value) ? value : 'standard';
|
||||
}
|
||||
|
||||
function preferredSize() {
|
||||
return SIZE_PRESETS[chatSize] || {
|
||||
width: Number(layout.customWidth) || DEFAULT_WIDTH,
|
||||
height: Number(layout.customHeight) || DEFAULT_HEIGHT
|
||||
};
|
||||
}
|
||||
|
||||
function clampDetached() {
|
||||
const viewport = viewportBounds();
|
||||
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
|
||||
@@ -265,6 +299,7 @@
|
||||
|
||||
function applyLayout() {
|
||||
applyingLayout = true;
|
||||
const size = preferredSize();
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
@@ -288,8 +323,8 @@
|
||||
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
||||
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
|
||||
panel.style.top = `${panelTop}px`;
|
||||
panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`;
|
||||
panel.style.height = `${Math.max(1, Math.min(560, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
|
||||
panel.style.width = `${Math.max(1, Math.min(size.width, viewport.width - gutter * 2))}px`;
|
||||
panel.style.height = `${Math.max(1, Math.min(size.height, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
|
||||
panel.style[layout.mode] = `${gutter}px`;
|
||||
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
|
||||
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
|
||||
@@ -298,16 +333,35 @@
|
||||
globalThis.queueMicrotask(() => { applyingLayout = false; });
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
if (!['left', 'right', 'detached'].includes(mode)) return;
|
||||
function setMode(mode, persistPreference = true) {
|
||||
mode = normalizePosition(mode);
|
||||
if (mode === 'detached' && !layout.detachedInitialized) {
|
||||
layout.x = Math.max(8, Math.round((window.innerWidth - DEFAULT_WIDTH) / 2));
|
||||
layout.y = Math.max(8, Math.round((window.innerHeight - DEFAULT_HEIGHT) / 2));
|
||||
const size = preferredSize();
|
||||
layout.x = Math.max(8, Math.round((window.innerWidth - size.width) / 2));
|
||||
layout.y = Math.max(8, Math.round((window.innerHeight - size.height) / 2));
|
||||
layout.detachedInitialized = true;
|
||||
}
|
||||
chatPosition = mode;
|
||||
layout.mode = mode;
|
||||
applyLayout();
|
||||
saveLayout();
|
||||
if (persistPreference) chrome.storage.local.set({ chatPosition: mode }).catch(() => {});
|
||||
}
|
||||
|
||||
function setSize(size, persistPreference = true) {
|
||||
chatSize = normalizeSize(size);
|
||||
const preset = SIZE_PRESETS[chatSize];
|
||||
if (preset) {
|
||||
layout.width = preset.width;
|
||||
layout.height = preset.height;
|
||||
} else {
|
||||
layout.width = Number(layout.customWidth) || DEFAULT_WIDTH;
|
||||
layout.height = Number(layout.customHeight) || DEFAULT_HEIGHT;
|
||||
}
|
||||
if (layout.mode === 'detached') clampDetached();
|
||||
applyLayout();
|
||||
saveLayout();
|
||||
if (persistPreference) chrome.storage.local.set({ chatSize }).catch(() => {});
|
||||
}
|
||||
|
||||
function setOpened(next) {
|
||||
@@ -324,13 +378,23 @@
|
||||
const previousRoomId = context?.roomId;
|
||||
context = next || null;
|
||||
const supported = !!context?.supported;
|
||||
const optedIn = !!context?.enabled;
|
||||
const hasKey = !!context?.hasKey;
|
||||
const connected = !!context?.connected;
|
||||
context = context ? { ...context, enabled: supported && hasKey && connected } : null;
|
||||
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages();
|
||||
host.style.display = supported ? '' : 'none';
|
||||
context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
|
||||
if (previousRoomId && previousRoomId !== context?.roomId) {
|
||||
clearMessages();
|
||||
startStateApplied = false;
|
||||
}
|
||||
host.style.display = supported && optedIn ? '' : 'none';
|
||||
launcher.setAttribute('aria-disabled', String(!context?.enabled));
|
||||
if (!context?.enabled) setOpened(false);
|
||||
if (!optedIn) startStateApplied = false;
|
||||
if (!context?.enabled) {
|
||||
setOpened(false);
|
||||
} else if (preferencesLoaded && !startStateApplied) {
|
||||
startStateApplied = true;
|
||||
setOpened(chatStartMode === 'open');
|
||||
}
|
||||
applyStrings();
|
||||
applyLayout();
|
||||
}
|
||||
@@ -450,8 +514,15 @@
|
||||
if (applyingLayout || layout.mode !== 'detached') return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
if (!opened || !rect?.width || !rect.height) return;
|
||||
if (Math.abs(rect.width - layout.width) < 1 && Math.abs(rect.height - layout.height) < 1) return;
|
||||
layout.width = rect.width;
|
||||
layout.height = rect.height;
|
||||
layout.customWidth = rect.width;
|
||||
layout.customHeight = rect.height;
|
||||
if (chatSize !== 'custom') {
|
||||
chatSize = 'custom';
|
||||
chrome.storage.local.set({ chatSize }).catch(() => {});
|
||||
}
|
||||
clampDetached();
|
||||
saveLayout();
|
||||
});
|
||||
@@ -480,6 +551,15 @@
|
||||
if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system';
|
||||
if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus';
|
||||
if (changes.themeMode || changes.themePalette) applyTheme();
|
||||
if (changes.chatPosition) setMode(changes.chatPosition.newValue, false);
|
||||
if (changes.chatSize) setSize(changes.chatSize.newValue, false);
|
||||
if (changes.chatStartMode) {
|
||||
chatStartMode = changes.chatStartMode.newValue === 'open' ? 'open' : 'bubble';
|
||||
if (context?.enabled) {
|
||||
startStateApplied = true;
|
||||
setOpened(chatStartMode === 'open');
|
||||
}
|
||||
}
|
||||
if (changes.locale) refresh();
|
||||
}
|
||||
|
||||
@@ -515,16 +595,30 @@
|
||||
systemTheme.addEventListener('change', handleSystemTheme);
|
||||
chrome.storage.onChanged.addListener(handleStorage);
|
||||
chrome.runtime.onMessage.addListener(handleRuntime);
|
||||
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette'], data => {
|
||||
if (data[storageKey] && typeof data[storageKey] === 'object') layout = { ...layout, ...data[storageKey] };
|
||||
if (!['left', 'right', 'detached'].includes(layout.mode)) layout.mode = 'right';
|
||||
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode'], data => {
|
||||
const storedLayout = data[storageKey];
|
||||
if (storedLayout && typeof storedLayout === 'object') {
|
||||
layout = { ...layout, ...storedLayout };
|
||||
layout.customWidth = Number(storedLayout.customWidth) || Number(storedLayout.width) || DEFAULT_WIDTH;
|
||||
layout.customHeight = Number(storedLayout.customHeight) || Number(storedLayout.height) || DEFAULT_HEIGHT;
|
||||
}
|
||||
chatPosition = normalizePosition(data.chatPosition);
|
||||
chatSize = normalizeSize(data.chatSize);
|
||||
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
|
||||
layout.mode = chatPosition;
|
||||
if (layout.mode === 'detached') layout.detachedInitialized = true;
|
||||
const preset = SIZE_PRESETS[chatSize];
|
||||
if (preset) {
|
||||
layout.width = preset.width;
|
||||
layout.height = preset.height;
|
||||
}
|
||||
themeMode = data.themeMode || 'system';
|
||||
themePalette = data.themePalette || 'eucalyptus';
|
||||
preferencesLoaded = true;
|
||||
applyTheme();
|
||||
applyLayout();
|
||||
refresh();
|
||||
});
|
||||
|
||||
window.koalaSyncChatOverlay = { refresh, destroy };
|
||||
refresh();
|
||||
})();
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an.",
|
||||
"LABEL_CHAT_ENABLED": "Raum-Chat aktivieren",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Verschlüsselten Raum-Chat im ausgewählten Video-Tab anzeigen.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Darstellung und Position des verschlüsselten Raum-Chats festlegen.",
|
||||
"CHAT_SETTINGS_NOTE": "Optional und Ende-zu-Ende verschlüsselt. Nachrichten sind nur live sichtbar und werden vom Relay nicht gespeichert.",
|
||||
"LABEL_CHAT_POSITION": "Position",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Lege fest, wo der Chat am ausgewählten Player angezeigt wird.",
|
||||
"LABEL_CHAT_SIZE": "Overlay-Größe",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Wähle die Standardgröße des Chat-Overlays.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Kompakt",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Standard",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Groß",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Letzte eigene Größe",
|
||||
"LABEL_CHAT_START_MODE": "Startansicht",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Lege fest, ob der Chat als schwebende Blase oder geöffnetes Overlay startet.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Schwebende Chatblase",
|
||||
"OPTION_CHAT_START_OPEN": "Geöffnetes Overlay",
|
||||
"CHAT_TITLE": "Raum-Chat",
|
||||
"CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.",
|
||||
"CHAT_OPEN": "Raum-Chat öffnen",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences.",
|
||||
"LABEL_CHAT_ENABLED": "Enable Room Chat",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Show encrypted room chat on the selected video tab.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure encrypted room chat appearance and placement.",
|
||||
"CHAT_SETTINGS_NOTE": "Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.",
|
||||
"LABEL_CHAT_POSITION": "Position",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Choose where chat is attached to the selected player.",
|
||||
"LABEL_CHAT_SIZE": "Overlay size",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Choose the default size of the chat overlay.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Compact",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Standard",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Large",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Last custom size",
|
||||
"LABEL_CHAT_START_MODE": "Start as",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Choose whether chat starts as a floating bubble or an open overlay.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Floating bubble",
|
||||
"OPTION_CHAT_START_OPEN": "Open overlay",
|
||||
"CHAT_TITLE": "Room Chat",
|
||||
"CHAT_LIVE_ONLY": "Live only. No history.",
|
||||
"CHAT_OPEN": "Open room chat",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Cambia tu nombre de usuario, tema y preferencias de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reproducción y Sincronización",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio.",
|
||||
"LABEL_CHAT_ENABLED": "Activar chat de la sala",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar el chat cifrado de la sala en la pestaña de vídeo seleccionada.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configura la apariencia y la posición del chat cifrado de la sala.",
|
||||
"CHAT_SETTINGS_NOTE": "Opcional y cifrado de extremo a extremo. Los mensajes solo se muestran en directo y el relé no los almacena.",
|
||||
"LABEL_CHAT_POSITION": "Posición",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Elige dónde se acopla el chat al reproductor seleccionado.",
|
||||
"LABEL_CHAT_SIZE": "Tamaño de la superposición",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Elige el tamaño predeterminado de la superposición del chat.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Estándar",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Grande",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Último tamaño personalizado",
|
||||
"LABEL_CHAT_START_MODE": "Iniciar como",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Elige si el chat comienza como una burbuja flotante o una superposición abierta.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Burbuja flotante",
|
||||
"OPTION_CHAT_START_OPEN": "Superposición abierta",
|
||||
"CHAT_TITLE": "Chat de la sala",
|
||||
"CHAT_LIVE_ONLY": "Solo en directo. Sin historial.",
|
||||
"CHAT_OPEN": "Abrir chat de la sala",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifiez votre nom d'utilisateur, votre thème et vos préférences linguistiques.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Lecture & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio.",
|
||||
"LABEL_CHAT_ENABLED": "Activer le chat du salon",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Afficher le chat chiffré du salon dans l'onglet vidéo sélectionné.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configurez l'apparence et l'emplacement du chat chiffré du salon.",
|
||||
"CHAT_SETTINGS_NOTE": "Facultatif et chiffré de bout en bout. Les messages sont uniquement en direct et ne sont pas stockés par le relais.",
|
||||
"LABEL_CHAT_POSITION": "Position",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Choisissez où le chat est ancré au lecteur sélectionné.",
|
||||
"LABEL_CHAT_SIZE": "Taille de la fenêtre",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Choisissez la taille par défaut de la fenêtre de chat.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Compacte",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Standard",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Grande",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Dernière taille personnalisée",
|
||||
"LABEL_CHAT_START_MODE": "Affichage initial",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Choisissez si le chat démarre sous forme de bulle flottante ou de fenêtre ouverte.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Bulle flottante",
|
||||
"OPTION_CHAT_START_OPEN": "Fenêtre ouverte",
|
||||
"CHAT_TITLE": "Chat du salon",
|
||||
"CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.",
|
||||
"CHAT_OPEN": "Ouvrir le chat du salon",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio.",
|
||||
"LABEL_CHAT_ENABLED": "Attiva chat della stanza",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostra la chat crittografata della stanza nella scheda video selezionata.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configura l'aspetto e la posizione della chat crittografata della stanza.",
|
||||
"CHAT_SETTINGS_NOTE": "Opzionale e crittografata end-to-end. I messaggi sono solo in diretta e non vengono memorizzati dal relay.",
|
||||
"LABEL_CHAT_POSITION": "Posizione",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Scegli dove agganciare la chat al lettore selezionato.",
|
||||
"LABEL_CHAT_SIZE": "Dimensione overlay",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Scegli la dimensione predefinita dell'overlay della chat.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Compatto",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Standard",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Grande",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Ultima dimensione personalizzata",
|
||||
"LABEL_CHAT_START_MODE": "Avvio come",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Scegli se la chat si avvia come bolla mobile o overlay aperto.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Bolla mobile",
|
||||
"OPTION_CHAT_START_OPEN": "Overlay aperto",
|
||||
"CHAT_TITLE": "Chat della stanza",
|
||||
"CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
|
||||
"CHAT_OPEN": "Apri la chat della stanza",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。",
|
||||
"LABEL_CHAT_ENABLED": "ルームチャットを有効化",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "選択した動画タブに暗号化されたルームチャットを表示します。",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "暗号化されたルームチャットの外観と位置を設定します。",
|
||||
"CHAT_SETTINGS_NOTE": "任意で利用でき、エンドツーエンドで暗号化されます。メッセージはリアルタイムのみで、リレーには保存されません。",
|
||||
"LABEL_CHAT_POSITION": "位置",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "選択したプレーヤーのどこにチャットを配置するか選択します。",
|
||||
"LABEL_CHAT_SIZE": "オーバーレイサイズ",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "チャットオーバーレイの標準サイズを選択します。",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "コンパクト",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "標準",
|
||||
"OPTION_CHAT_SIZE_LARGE": "大",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "前回のカスタムサイズ",
|
||||
"LABEL_CHAT_START_MODE": "開始表示",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "フローティングバブルまたは開いたオーバーレイで開始するか選択します。",
|
||||
"OPTION_CHAT_START_BUBBLE": "フローティングバブル",
|
||||
"OPTION_CHAT_START_OPEN": "開いたオーバーレイ",
|
||||
"CHAT_TITLE": "ルームチャット",
|
||||
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
|
||||
"CHAT_OPEN": "ルームチャットを開く",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.",
|
||||
"LABEL_CHAT_ENABLED": "방 채팅 사용",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "암호화된 방 채팅의 모양과 위치를 설정합니다.",
|
||||
"CHAT_SETTINGS_NOTE": "선택 기능이며 종단 간 암호화됩니다. 메시지는 실시간으로만 표시되고 릴레이에 저장되지 않습니다.",
|
||||
"LABEL_CHAT_POSITION": "위치",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "선택한 플레이어에서 채팅을 고정할 위치를 선택합니다.",
|
||||
"LABEL_CHAT_SIZE": "오버레이 크기",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "채팅 오버레이의 기본 크기를 선택합니다.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "작게",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "표준",
|
||||
"OPTION_CHAT_SIZE_LARGE": "크게",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "마지막 사용자 지정 크기",
|
||||
"LABEL_CHAT_START_MODE": "시작 화면",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "플로팅 버블 또는 열린 오버레이로 시작할지 선택합니다.",
|
||||
"OPTION_CHAT_START_BUBBLE": "플로팅 채팅 버블",
|
||||
"OPTION_CHAT_START_OPEN": "열린 오버레이",
|
||||
"CHAT_TITLE": "방 채팅",
|
||||
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
|
||||
"CHAT_OPEN": "방 채팅 열기",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan.",
|
||||
"LABEL_CHAT_ENABLED": "Kamerchat inschakelen",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Versleutelde kamerchat tonen in het geselecteerde videotabblad.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Pas het uiterlijk en de positie van de versleutelde kamerchat aan.",
|
||||
"CHAT_SETTINGS_NOTE": "Optioneel en end-to-end versleuteld. Berichten zijn alleen live en worden niet door de relay opgeslagen.",
|
||||
"LABEL_CHAT_POSITION": "Positie",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Kies waar de chat aan de geselecteerde speler wordt gekoppeld.",
|
||||
"LABEL_CHAT_SIZE": "Overlaygrootte",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Kies de standaardgrootte van de chatoverlay.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Compact",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Standaard",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Groot",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Laatste aangepaste grootte",
|
||||
"LABEL_CHAT_START_MODE": "Start als",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Kies of de chat start als zwevende knop of als geopende overlay.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Zwevende chatknop",
|
||||
"OPTION_CHAT_START_OPEN": "Geopende overlay",
|
||||
"CHAT_TITLE": "Kamerchat",
|
||||
"CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
|
||||
"CHAT_OPEN": "Kamerchat openen",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio.",
|
||||
"LABEL_CHAT_ENABLED": "Włącz czat pokoju",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Pokaż zaszyfrowany czat pokoju na wybranej karcie wideo.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Skonfiguruj wygląd i położenie zaszyfrowanego czatu pokoju.",
|
||||
"CHAT_SETTINGS_NOTE": "Opcjonalny i szyfrowany od końca do końca. Wiadomości są tylko na żywo i nie są przechowywane przez serwer pośredniczący.",
|
||||
"LABEL_CHAT_POSITION": "Położenie",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Wybierz, gdzie czat ma być przypięty do wybranego odtwarzacza.",
|
||||
"LABEL_CHAT_SIZE": "Rozmiar nakładki",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Wybierz domyślny rozmiar nakładki czatu.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Kompaktowy",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Standardowy",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Duży",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Ostatni własny rozmiar",
|
||||
"LABEL_CHAT_START_MODE": "Uruchom jako",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Wybierz, czy czat ma startować jako pływający przycisk, czy otwarta nakładka.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Pływający przycisk",
|
||||
"OPTION_CHAT_START_OPEN": "Otwarta nakładka",
|
||||
"CHAT_TITLE": "Czat pokoju",
|
||||
"CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.",
|
||||
"CHAT_OPEN": "Otwórz czat pokoju",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de usuário, tema e preferências de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
|
||||
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat criptografado da sala na guia de vídeo selecionada.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure a aparência e a posição do chat criptografado da sala.",
|
||||
"CHAT_SETTINGS_NOTE": "Opcional e criptografado de ponta a ponta. As mensagens são apenas ao vivo e não são armazenadas pelo relay.",
|
||||
"LABEL_CHAT_POSITION": "Posição",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Escolha onde o chat fica fixado no player selecionado.",
|
||||
"LABEL_CHAT_SIZE": "Tamanho da sobreposição",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Escolha o tamanho padrão da sobreposição do chat.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Padrão",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Grande",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Último tamanho personalizado",
|
||||
"LABEL_CHAT_START_MODE": "Iniciar como",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
|
||||
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
|
||||
"CHAT_TITLE": "Chat da sala",
|
||||
"CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
|
||||
"CHAT_OPEN": "Abrir chat da sala",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de utilizador, tema e preferências de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
|
||||
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat encriptado da sala no separador de vídeo selecionado.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure o aspeto e a posição do chat encriptado da sala.",
|
||||
"CHAT_SETTINGS_NOTE": "Opcional e encriptado ponto a ponto. As mensagens são apenas em direto e não são guardadas pelo relay.",
|
||||
"LABEL_CHAT_POSITION": "Posição",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Escolha onde o chat fica fixado no leitor selecionado.",
|
||||
"LABEL_CHAT_SIZE": "Tamanho da sobreposição",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Escolha o tamanho predefinido da sobreposição do chat.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Padrão",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Grande",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Último tamanho personalizado",
|
||||
"LABEL_CHAT_START_MODE": "Iniciar como",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
|
||||
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
|
||||
"CHAT_TITLE": "Chat da sala",
|
||||
"CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.",
|
||||
"CHAT_OPEN": "Abrir chat da sala",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.",
|
||||
"LABEL_CHAT_ENABLED": "Включить чат комнаты",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Настройте внешний вид и расположение зашифрованного чата комнаты.",
|
||||
"CHAT_SETTINGS_NOTE": "Необязательный чат со сквозным шифрованием. Сообщения доступны только в реальном времени и не сохраняются ретранслятором.",
|
||||
"LABEL_CHAT_POSITION": "Положение",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Выберите, где чат будет закреплён на выбранном проигрывателе.",
|
||||
"LABEL_CHAT_SIZE": "Размер окна",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Выберите стандартный размер окна чата.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Компактный",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Стандартный",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Большой",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Последний пользовательский размер",
|
||||
"LABEL_CHAT_START_MODE": "Начальный вид",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Выберите, запускать чат как плавающую кнопку или открытое окно.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Плавающая кнопка",
|
||||
"OPTION_CHAT_START_OPEN": "Открытое окно",
|
||||
"CHAT_TITLE": "Чат комнаты",
|
||||
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
|
||||
"CHAT_OPEN": "Открыть чат комнаты",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Kullanıcı adınızı, temanızı ve dil tercihlerinizi değiştirin.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Oynatma ve Eşitleme",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin.",
|
||||
"LABEL_CHAT_ENABLED": "Oda sohbetini etkinleştir",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Şifreli oda sohbetini seçili video sekmesinde göster.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Şifreli oda sohbetinin görünümünü ve konumunu yapılandırın.",
|
||||
"CHAT_SETTINGS_NOTE": "İsteğe bağlı ve uçtan uca şifrelidir. Mesajlar yalnızca canlıdır ve aktarıcı tarafından saklanmaz.",
|
||||
"LABEL_CHAT_POSITION": "Konum",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Sohbetin seçili oynatıcıda nereye sabitleneceğini seçin.",
|
||||
"LABEL_CHAT_SIZE": "Kaplama boyutu",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Sohbet kaplamasının varsayılan boyutunu seçin.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Kompakt",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Standart",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Büyük",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Son özel boyut",
|
||||
"LABEL_CHAT_START_MODE": "Başlangıç görünümü",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Sohbetin kayan balon veya açık kaplama olarak başlamasını seçin.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Kayan sohbet balonu",
|
||||
"OPTION_CHAT_START_OPEN": "Açık kaplama",
|
||||
"CHAT_TITLE": "Oda Sohbeti",
|
||||
"CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
|
||||
"CHAT_OPEN": "Oda sohbetini aç",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.",
|
||||
"LABEL_CHAT_ENABLED": "Увімкнути чат кімнати",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Налаштуйте вигляд і розташування зашифрованого чату кімнати.",
|
||||
"CHAT_SETTINGS_NOTE": "Необов'язковий чат із наскрізним шифруванням. Повідомлення доступні лише наживо й не зберігаються ретранслятором.",
|
||||
"LABEL_CHAT_POSITION": "Розташування",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "Виберіть, де чат буде закріплено на вибраному програвачі.",
|
||||
"LABEL_CHAT_SIZE": "Розмір вікна",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "Виберіть стандартний розмір вікна чату.",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "Компактний",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "Стандартний",
|
||||
"OPTION_CHAT_SIZE_LARGE": "Великий",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "Останній власний розмір",
|
||||
"LABEL_CHAT_START_MODE": "Початковий вигляд",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "Виберіть, запускати чат як плаваючу кнопку чи відкрите вікно.",
|
||||
"OPTION_CHAT_START_BUBBLE": "Плаваюча кнопка",
|
||||
"OPTION_CHAT_START_OPEN": "Відкрите вікно",
|
||||
"CHAT_TITLE": "Чат кімнати",
|
||||
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
|
||||
"CHAT_OPEN": "Відкрити чат кімнати",
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。",
|
||||
"LABEL_CHAT_ENABLED": "启用房间聊天",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "在选定的视频标签页中显示加密的房间聊天。",
|
||||
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "配置加密房间聊天的外观和位置。",
|
||||
"CHAT_SETTINGS_NOTE": "可选并采用端到端加密。消息仅实时显示,且不会由中继服务器存储。",
|
||||
"LABEL_CHAT_POSITION": "位置",
|
||||
"LABEL_CHAT_POSITION_TOOLTIP": "选择聊天在当前播放器上的固定位置。",
|
||||
"LABEL_CHAT_SIZE": "浮层大小",
|
||||
"LABEL_CHAT_SIZE_TOOLTIP": "选择聊天浮层的默认大小。",
|
||||
"OPTION_CHAT_SIZE_COMPACT": "紧凑",
|
||||
"OPTION_CHAT_SIZE_STANDARD": "标准",
|
||||
"OPTION_CHAT_SIZE_LARGE": "大",
|
||||
"OPTION_CHAT_SIZE_CUSTOM": "上次自定义大小",
|
||||
"LABEL_CHAT_START_MODE": "启动方式",
|
||||
"LABEL_CHAT_START_MODE_TOOLTIP": "选择以悬浮气泡或打开的浮层启动聊天。",
|
||||
"OPTION_CHAT_START_BUBBLE": "悬浮聊天气泡",
|
||||
"OPTION_CHAT_START_OPEN": "打开浮层",
|
||||
"CHAT_TITLE": "房间聊天",
|
||||
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
|
||||
"CHAT_OPEN": "打开房间聊天",
|
||||
|
||||
@@ -1215,6 +1215,12 @@
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
[data-chat-setting] {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
[data-chat-setting].is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.settings-issue-link {
|
||||
margin: 8px 0 2px;
|
||||
padding-top: 9px;
|
||||
@@ -1615,6 +1621,50 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="form-group" name="settings-accordion">
|
||||
<summary title="Configure encrypted room chat appearance and placement." data-i18n="CHAT_TITLE" data-i18n-title="LABEL_SETTINGS_GROUP_CHAT_TOOLTIP">Room Chat</summary>
|
||||
<div class="details-content">
|
||||
<p class="settings-note" data-i18n="CHAT_SETTINGS_NOTE">Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.</p>
|
||||
<div class="settings-row">
|
||||
<label for="chatEnabled" title="Show encrypted room chat on the selected video tab." data-i18n="LABEL_CHAT_ENABLED" data-i18n-title="LABEL_CHAT_ENABLED_TOOLTIP">Enable Room Chat</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="chatEnabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-row" data-chat-setting>
|
||||
<label for="chatPosition" title="Choose where chat is attached to the selected player." data-i18n="LABEL_CHAT_POSITION" data-i18n-title="LABEL_CHAT_POSITION_TOOLTIP">Position</label>
|
||||
<span class="settings-select">
|
||||
<select id="chatPosition" class="settings-control">
|
||||
<option value="right" data-i18n="CHAT_DOCK_RIGHT">Dock chat right</option>
|
||||
<option value="left" data-i18n="CHAT_DOCK_LEFT">Dock chat left</option>
|
||||
<option value="detached" data-i18n="CHAT_DETACHED">Detach chat</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="settings-row" data-chat-setting>
|
||||
<label for="chatSize" title="Choose the default size of the chat overlay." data-i18n="LABEL_CHAT_SIZE" data-i18n-title="LABEL_CHAT_SIZE_TOOLTIP">Overlay size</label>
|
||||
<span class="settings-select">
|
||||
<select id="chatSize" class="settings-control">
|
||||
<option value="compact" data-i18n="OPTION_CHAT_SIZE_COMPACT">Compact</option>
|
||||
<option value="standard" data-i18n="OPTION_CHAT_SIZE_STANDARD">Standard</option>
|
||||
<option value="large" data-i18n="OPTION_CHAT_SIZE_LARGE">Large</option>
|
||||
<option value="custom" data-i18n="OPTION_CHAT_SIZE_CUSTOM">Last custom size</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="settings-row" data-chat-setting>
|
||||
<label for="chatStartMode" title="Choose whether chat starts as a floating bubble or an open overlay." data-i18n="LABEL_CHAT_START_MODE" data-i18n-title="LABEL_CHAT_START_MODE_TOOLTIP">Start as</label>
|
||||
<span class="settings-select">
|
||||
<select id="chatStartMode" class="settings-control">
|
||||
<option value="bubble" data-i18n="OPTION_CHAT_START_BUBBLE">Floating bubble</option>
|
||||
<option value="open" data-i18n="OPTION_CHAT_START_OPEN">Open overlay</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="form-group" name="settings-accordion">
|
||||
<summary title="Customize playback, notifications, and audio preferences." data-i18n="LABEL_SETTINGS_GROUP_SYNC" data-i18n-title="LABEL_SETTINGS_GROUP_SYNC_TOOLTIP">Playback & Sync</summary>
|
||||
<div class="details-content">
|
||||
|
||||
+45
-1
@@ -68,6 +68,10 @@ const elements = {
|
||||
playBtn: document.getElementById('playBtn'),
|
||||
pauseBtn: document.getElementById('pauseBtn'),
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
chatEnabled: document.getElementById('chatEnabled'),
|
||||
chatPosition: document.getElementById('chatPosition'),
|
||||
chatSize: document.getElementById('chatSize'),
|
||||
chatStartMode: document.getElementById('chatStartMode'),
|
||||
sendTabTitle: document.getElementById('sendTabTitle'),
|
||||
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
@@ -337,7 +341,7 @@ function setRoomRefreshCooldown() {
|
||||
async function init() {
|
||||
// Local-only by design — settings and room credentials never come from
|
||||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -368,6 +372,11 @@ async function init() {
|
||||
syncDevToolsVisibility();
|
||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
|
||||
if (elements.chatPosition) elements.chatPosition.value = ['left', 'detached'].includes(localData.chatPosition) ? localData.chatPosition : 'right';
|
||||
if (elements.chatSize) elements.chatSize.value = ['compact', 'large', 'custom'].includes(localData.chatSize) ? localData.chatSize : 'standard';
|
||||
if (elements.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble';
|
||||
syncChatSettingsState();
|
||||
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
|
||||
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
|
||||
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
|
||||
@@ -1456,6 +1465,41 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
function syncChatSettingsState() {
|
||||
const enabled = elements.chatEnabled?.checked === true;
|
||||
for (const control of [elements.chatPosition, elements.chatSize, elements.chatStartMode]) {
|
||||
if (control) control.disabled = !enabled;
|
||||
}
|
||||
document.querySelectorAll('[data-chat-setting]').forEach(row => {
|
||||
row.classList.toggle('is-disabled', !enabled);
|
||||
row.setAttribute('aria-disabled', String(!enabled));
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.chatEnabled) {
|
||||
elements.chatEnabled.addEventListener('change', () => {
|
||||
syncChatSettingsState();
|
||||
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
|
||||
});
|
||||
}
|
||||
if (elements.chatPosition) {
|
||||
elements.chatPosition.addEventListener('change', () => {
|
||||
const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right';
|
||||
chrome.storage.local.set({ chatPosition });
|
||||
});
|
||||
}
|
||||
if (elements.chatSize) {
|
||||
elements.chatSize.addEventListener('change', () => {
|
||||
const chatSize = ['compact', 'large', 'custom'].includes(elements.chatSize.value) ? elements.chatSize.value : 'standard';
|
||||
chrome.storage.local.set({ chatSize });
|
||||
});
|
||||
}
|
||||
if (elements.chatStartMode) {
|
||||
elements.chatStartMode.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ chatStartMode: elements.chatStartMode.value === 'open' ? 'open' : 'bubble' });
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.sendTabTitle) {
|
||||
elements.sendTabTitle.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
|
||||
|
||||
Generated
+783
-1254
File diff suppressed because it is too large
Load Diff
+16
-8
@@ -4,6 +4,9 @@
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"indexnow": "node website/submit-indexnow.cjs",
|
||||
@@ -15,17 +18,22 @@
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"addons-linter": "^10.9.0",
|
||||
"archiver": "^7.0.1",
|
||||
"archiver": "^8.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-no-unsanitized": "^4.1.5",
|
||||
"fs-extra": "^11.2.0",
|
||||
"fs-extra": "^11.4.0",
|
||||
"htmlnano": "^3.4.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1",
|
||||
"vitest": "^4.1.9"
|
||||
"sharp": "^0.35.3",
|
||||
"svgo": "^4.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "5.0.8",
|
||||
"fast-uri": "3.1.4",
|
||||
"postcss": "8.5.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const extDir = path.join(rootDir, 'extension');
|
||||
@@ -202,18 +201,18 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
}
|
||||
|
||||
// Helper to zip a directory
|
||||
function zipDirectory(sourceDir, outPath) {
|
||||
async function zipDirectory(sourceDir, outPath) {
|
||||
const { ZipArchive } = await import('archiver');
|
||||
return new Promise((resolve, reject) => {
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||
const stream = fs.createWriteStream(outPath);
|
||||
|
||||
archive
|
||||
.directory(sourceDir, false)
|
||||
.on('error', err => reject(err))
|
||||
.pipe(stream);
|
||||
|
||||
archive.on('error', reject);
|
||||
stream.on('close', () => resolve());
|
||||
archive.finalize();
|
||||
stream.on('error', reject);
|
||||
archive.pipe(stream);
|
||||
archive.directory(sourceDir, false);
|
||||
void archive.finalize().catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -23,7 +23,10 @@ async function c() {
|
||||
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
|
||||
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
|
||||
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
|
||||
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
|
||||
async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
|
||||
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
|
||||
assert.equal((await a(ws))[0],'room_data');
|
||||
}
|
||||
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
|
||||
// Test suite opens >10 connections/min — clear the IP connection counter so the
|
||||
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
|
||||
@@ -72,6 +75,7 @@ try {
|
||||
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
|
||||
'ROOM_DATA advertises the host-control capability');
|
||||
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
|
||||
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
|
||||
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
@@ -79,7 +83,8 @@ try {
|
||||
// --- Encrypted chat is a live-only canonical relay ---
|
||||
const chatRoom = 'chat-'+Date.now();
|
||||
const chat1 = await c(), chat2 = await c();
|
||||
await j(chat1, chatRoom, 'alice'); await j(chat2, chatRoom, 'bob');
|
||||
await j(chat1, chatRoom, 'alice', null, ['chat-v1']);
|
||||
await j(chat2, chatRoom, 'bob', null, ['chat-v1']);
|
||||
chat1._m.length = chat2._m.length = 0;
|
||||
const ciphertext = Buffer.alloc(64, 7).toString('base64url');
|
||||
s(chat1, 'chat_message', {
|
||||
@@ -106,6 +111,36 @@ try {
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Mixed rollout: old non-chat clients never receive unknown chat events ---
|
||||
const mixedChatRoom = 'chat-mixed-'+Date.now();
|
||||
const newChat = await c(), oldNoChat = await c();
|
||||
await j(newChat, mixedChatRoom, 'new-chat', null, ['chat-v1', 'chat-v1', 'future-chat', 42]);
|
||||
await j(oldNoChat, mixedChatRoom, 'old-no-chat', null, ['chat-v2']);
|
||||
newChat._m.length = oldNoChat._m.length = 0;
|
||||
s(newChat, 'chat_message', { ciphertext });
|
||||
await w(newChat, 'chat_message');
|
||||
let oldReceivedChat = false;
|
||||
try { await w(oldNoChat, 'chat_message', 500); oldReceivedChat = true; } catch { /* expected */ }
|
||||
assert.equal(oldReceivedChat, false, 'old client receives no unknown chat event');
|
||||
|
||||
// The same old client remains fully usable for the pre-chat protocol.
|
||||
newChat._m.length = oldNoChat._m.length = 0;
|
||||
s(oldNoChat, 'play', { currentTime: 12 });
|
||||
await w(newChat, 'play');
|
||||
s(newChat, 'pause', { currentTime: 13 });
|
||||
await w(oldNoChat, 'pause');
|
||||
|
||||
// First chat beta compatibility: sending a valid chat frame dynamically
|
||||
// proves receive support even when that beta omitted clientCapabilities.
|
||||
const firstBeta = await c();
|
||||
await j(firstBeta, mixedChatRoom, 'first-beta');
|
||||
firstBeta._m.length = newChat._m.length = 0;
|
||||
s(firstBeta, 'chat_message', { ciphertext });
|
||||
await w(firstBeta, 'chat_message');
|
||||
await w(newChat, 'chat_message');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Default 'everyone' mode does NOT gate anyone (host-control OFF = unchanged) ---
|
||||
// Confirms that with host-only off, a non-host guest can still drive every
|
||||
// room-moving event exactly like before the feature existed.
|
||||
|
||||
@@ -20,6 +20,7 @@ const checks = [
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
|
||||
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
|
||||
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
|
||||
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
|
||||
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
|
||||
['server syntax index', 'node', ['-c', 'server/index.js']],
|
||||
['server syntax ops', 'node', ['-c', 'server/ops.js']],
|
||||
|
||||
+6
-5
@@ -1,15 +1,16 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Copy shared protocol first
|
||||
COPY shared/ ./shared/
|
||||
COPY --chown=node:node shared/ ./shared/
|
||||
|
||||
# Copy server
|
||||
COPY server/package*.json ./server/
|
||||
RUN cd server && npm install --production
|
||||
COPY server/ ./server/
|
||||
COPY --chown=node:node server/package*.json ./server/
|
||||
RUN cd server && npm ci --omit=dev
|
||||
COPY --chown=node:node server/ ./server/
|
||||
|
||||
WORKDIR /app/server
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
+38
-2
@@ -174,7 +174,26 @@ const HOST_ONLY_GATED_EVENTS = new Set([
|
||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST, CAPABILITIES.CHAT];
|
||||
const SERVER_CAPABILITIES = [
|
||||
CAPABILITIES.HOST_CONTROL,
|
||||
CAPABILITIES.CO_HOST,
|
||||
CAPABILITIES.CHAT,
|
||||
CAPABILITIES.CHAT_V1
|
||||
];
|
||||
|
||||
function normalizeClientCapabilities(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.slice(0, 16)
|
||||
.filter(capability => typeof capability === 'string')
|
||||
.map(capability => capability.substring(0, 32))
|
||||
.filter(capability => capability === CAPABILITIES.CHAT_V1)
|
||||
)];
|
||||
}
|
||||
|
||||
function clientSupportsChat(socket) {
|
||||
return Array.isArray(socket?.data?.clientCapabilities) &&
|
||||
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
|
||||
}
|
||||
|
||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||
@@ -356,6 +375,7 @@ io.on('connection', (socket) => {
|
||||
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
|
||||
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
|
||||
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
|
||||
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
|
||||
|
||||
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
||||
|
||||
@@ -370,6 +390,7 @@ io.on('connection', (socket) => {
|
||||
// Cleanup old room if re-joining
|
||||
const oldMapping = socketToRoom.get(socket.id);
|
||||
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
|
||||
socket.data.clientCapabilities = clientCapabilities;
|
||||
return; // Already in this room with same peerId, ignore to prevent spam
|
||||
}
|
||||
if (oldMapping && oldMapping.roomId !== roomId) {
|
||||
@@ -493,6 +514,7 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
|
||||
socket.join(roomId);
|
||||
socket.data.clientCapabilities = clientCapabilities;
|
||||
room.peers.add(socket.id);
|
||||
room.peerIds.set(socket.id, peerId);
|
||||
room.peerData.set(socket.id, {
|
||||
@@ -862,8 +884,22 @@ io.on('connection', (socket) => {
|
||||
|
||||
const envelope = createChatEnvelope(data, mapping.peerId);
|
||||
if (!envelope) return;
|
||||
// Transitional compatibility: the first chat beta did not announce
|
||||
// clientCapabilities. Successfully sending a canonical chat frame is
|
||||
// sufficient proof that this socket understands the v1 receive event.
|
||||
if (!clientSupportsChat(socket)) {
|
||||
socket.data.clientCapabilities = [
|
||||
...(socket.data.clientCapabilities || []),
|
||||
CAPABILITIES.CHAT_V1
|
||||
];
|
||||
}
|
||||
room.lastActivity = Date.now();
|
||||
io.to(mapping.roomId).emit(EVENTS.CHAT_MESSAGE, envelope);
|
||||
for (const socketId of room.peers) {
|
||||
const recipient = io.sockets.sockets.get(socketId);
|
||||
if (clientSupportsChat(recipient)) {
|
||||
recipient.emit(EVENTS.CHAT_MESSAGE, envelope);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
|
||||
}
|
||||
|
||||
Generated
+90
-51
@@ -30,12 +30,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
@@ -70,20 +70,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -93,6 +93,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -347,9 +360,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -526,9 +539,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -558,9 +571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -604,12 +617,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
|
||||
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
@@ -739,12 +756,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -754,12 +772,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
@@ -851,14 +873,14 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -951,9 +973,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
@@ -1025,23 +1047,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@@ -1069,9 +1108,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
+2
-1
@@ -81,7 +81,8 @@ export const CONTROL_MODES = {
|
||||
export const CAPABILITIES = {
|
||||
HOST_CONTROL: 'host-control',
|
||||
CO_HOST: 'co-host', // owner promotes guests to additional controllers
|
||||
CHAT: 'chat'
|
||||
CHAT: 'chat', // legacy server capability used by the first chat beta
|
||||
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
|
||||
Reference in New Issue
Block a user