mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 213d2867fc | |||
| 90a58eb507 | |||
| 72113b6504 | |||
| a0954079a1 | |||
| 9251ae6aff | |||
| 511e042b0b | |||
| 372c384a19 |
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.4-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.0.0-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.4 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v3.0.0 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+264
-66
@@ -7,7 +7,14 @@ import { initTabManager } from './modules/tab-manager.js';
|
||||
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
|
||||
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
|
||||
import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js';
|
||||
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
|
||||
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest, containsOriginPermission } from './host-access.js';
|
||||
import {
|
||||
GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED,
|
||||
GOOGLE_DRIVE_PLAYER_AMBIGUOUS,
|
||||
GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN,
|
||||
isGoogleDriveUrl,
|
||||
resolveMediaScriptTarget
|
||||
} from './media-frame-target.js';
|
||||
import './page-api-seek-overrides.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
@@ -65,6 +72,8 @@ let peerId = null; // initialized via getPeerId()
|
||||
let currentRoom = null;
|
||||
let currentTabId = null;
|
||||
let currentTabTitle = null; // New: for Smart Matching
|
||||
let currentTargetFrameId = 0;
|
||||
let currentTargetDocumentId = null;
|
||||
let targetActivationGeneration = 0;
|
||||
let activeTargetActivation = null;
|
||||
let logs = [];
|
||||
@@ -193,12 +202,23 @@ function ensureState() {
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
|
||||
'currentTargetFrameId', 'currentTargetDocumentId',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
|
||||
'hcmDesynced'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
||||
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId);
|
||||
currentTargetFrameId = currentTabId !== null
|
||||
&& Number.isInteger(data.currentTargetFrameId)
|
||||
&& data.currentTargetFrameId >= 0
|
||||
? data.currentTargetFrameId
|
||||
: 0;
|
||||
currentTargetDocumentId = currentTabId !== null
|
||||
&& typeof data.currentTargetDocumentId === 'string'
|
||||
&& data.currentTargetDocumentId
|
||||
? data.currentTargetDocumentId
|
||||
: null;
|
||||
if (data.currentTabTitle !== undefined) {
|
||||
currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string'
|
||||
? data.currentTabTitle
|
||||
@@ -452,7 +472,8 @@ function emitEpisodeLobbyForCurrentPrivacy() {
|
||||
// removes legacy keys that older versions wrote to sync (and that would
|
||||
// otherwise be redistributed across devices and resurrected on reinstall).
|
||||
const LEGACY_SYNC_KEYS = [
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username',
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
|
||||
'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
|
||||
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
||||
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
||||
@@ -568,6 +589,77 @@ function isCurrentTargetIdentity(tabId, generation) {
|
||||
&& targetActivationGeneration === generation;
|
||||
}
|
||||
|
||||
function normalizeFrameId(value) {
|
||||
return Number.isInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function currentContentTarget() {
|
||||
return {
|
||||
frameId: normalizeFrameId(currentTargetFrameId),
|
||||
documentId: typeof currentTargetDocumentId === 'string' && currentTargetDocumentId
|
||||
? currentTargetDocumentId
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function sendMessageToFrame(tabId, frameId, message, callback = null, documentId = null) {
|
||||
const options = typeof documentId === 'string' && documentId
|
||||
? { documentId }
|
||||
: { frameId: normalizeFrameId(frameId) };
|
||||
if (typeof callback === 'function') {
|
||||
return chrome.tabs.sendMessage(tabId, message, options, callback);
|
||||
}
|
||||
return chrome.tabs.sendMessage(tabId, message, options);
|
||||
}
|
||||
|
||||
function sendMessageToCurrentContent(message, callback = null) {
|
||||
const tabId = normalizeTabId(currentTabId);
|
||||
if (tabId === null) {
|
||||
return typeof callback === 'function' ? undefined : Promise.reject(new Error('No target tab selected'));
|
||||
}
|
||||
return sendMessageToFrame(
|
||||
tabId,
|
||||
currentTargetFrameId,
|
||||
message,
|
||||
callback,
|
||||
currentTargetDocumentId
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessageToContentTab(tabId, message, callback = null) {
|
||||
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
|
||||
return sendMessageToCurrentContent(message, callback);
|
||||
}
|
||||
if (typeof callback === 'function') {
|
||||
return chrome.tabs.sendMessage(tabId, message, callback);
|
||||
}
|
||||
return chrome.tabs.sendMessage(tabId, message);
|
||||
}
|
||||
|
||||
function isCurrentContentSender(sender) {
|
||||
if (!sender?.tab) return false;
|
||||
const senderTabId = normalizeTabId(sender.tab.id);
|
||||
const senderFrameId = normalizeFrameId(sender.frameId);
|
||||
const matchesActive = senderTabId === normalizeTabId(activeTargetActivation?.tabId)
|
||||
&& senderFrameId === normalizeFrameId(activeTargetActivation?.frameId)
|
||||
&& (!activeTargetActivation?.documentId
|
||||
|| sender.documentId === activeTargetActivation.documentId);
|
||||
if (Number.isInteger(activeTargetActivation?.frameId)) return matchesActive;
|
||||
return senderTabId === normalizeTabId(currentTabId)
|
||||
&& senderFrameId === normalizeFrameId(currentTargetFrameId)
|
||||
&& (!currentTargetDocumentId || sender.documentId === currentTargetDocumentId);
|
||||
}
|
||||
|
||||
function sameContentTarget(left, right) {
|
||||
return normalizeFrameId(left?.frameId) === normalizeFrameId(right?.frameId)
|
||||
&& (!left?.documentId || !right?.documentId || left.documentId === right.documentId);
|
||||
}
|
||||
|
||||
function clearCurrentContentTarget() {
|
||||
currentTargetFrameId = 0;
|
||||
currentTargetDocumentId = null;
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
|
||||
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
|
||||
return false;
|
||||
@@ -579,9 +671,10 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
invalidateTargetActivations();
|
||||
clearPendingTarget().catch(() => {});
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
@@ -589,6 +682,8 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
|
||||
chrome.storage.session.set({
|
||||
currentTabId,
|
||||
currentTabTitle,
|
||||
currentTargetFrameId,
|
||||
currentTargetDocumentId,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt
|
||||
}).catch(() => {});
|
||||
@@ -614,10 +709,11 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
|
||||
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
|
||||
broadcastControlMode();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
clearEpisodeLobbyState();
|
||||
@@ -626,6 +722,8 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
currentRoom: null,
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
currentTargetFrameId: 0,
|
||||
currentTargetDocumentId: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
episodeLobby: null,
|
||||
@@ -845,7 +943,7 @@ function broadcastControlMode() {
|
||||
chrome.runtime.sendMessage(payload).catch(() => {});
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) chrome.tabs.sendMessage(tabId, payload).catch(() => {});
|
||||
if (!isNaN(tabId)) sendMessageToContentTab(tabId, payload).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,7 +955,7 @@ function broadcastConnectionStatus(status) {
|
||||
status = 'idle';
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
@@ -1120,7 +1218,7 @@ async function handleServerEvent(event, data) {
|
||||
hostPeerId = data.hostPeerId || null;
|
||||
controllers = Array.isArray(data.controllers) ? data.controllers : [];
|
||||
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
hcmEnforceDesyncInvariant();
|
||||
broadcastControlMode();
|
||||
markRoomPotentiallyIdle();
|
||||
@@ -1155,7 +1253,7 @@ async function handleServerEvent(event, data) {
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
sendMessageToContentTab(tabId, {
|
||||
type: 'EPISODE_LOBBY',
|
||||
expectedTitle: episodeLobby.expectedTitle
|
||||
}).catch(() => {});
|
||||
@@ -1223,7 +1321,7 @@ async function handleServerEvent(event, data) {
|
||||
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
|
||||
);
|
||||
if (Number.isInteger(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
sendMessageToContentTab(tabId, {
|
||||
type: 'CHAT_MESSAGE',
|
||||
message: {
|
||||
id: received.id,
|
||||
@@ -1407,7 +1505,7 @@ async function handleServerEvent(event, data) {
|
||||
// current playback state so the newcomer syncs immediately
|
||||
// instead of waiting up to a full heartbeat interval.
|
||||
if (wasSolo && currentTabId) {
|
||||
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
||||
sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
||||
}
|
||||
|
||||
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
||||
@@ -1497,7 +1595,7 @@ async function handleServerEvent(event, data) {
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
sendMessageToContentTab(tabId, {
|
||||
type: 'EPISODE_LOBBY',
|
||||
expectedTitle: data.expectedTitle
|
||||
}).catch(() => {});
|
||||
@@ -1609,7 +1707,7 @@ function clearEpisodeLobbyState() {
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {});
|
||||
sendMessageToContentTab(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1738,7 +1836,19 @@ async function routeToContent(action, payload) {
|
||||
|
||||
const tabId = normalizeTabId(currentTabId);
|
||||
if (tabId === null) return;
|
||||
const targetGeneration = targetActivationGeneration;
|
||||
let targetGeneration = targetActivationGeneration;
|
||||
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (isGoogleDriveUrl(tab?.url || '')) {
|
||||
const activation = await reactivateCurrentTarget(tabId, { expectedGeneration: targetGeneration });
|
||||
if (activation?.status !== 'ok') return;
|
||||
targetGeneration = activation.generation;
|
||||
}
|
||||
} catch (error) {
|
||||
addLog(`Google Drive target refresh failed: ${error.message}`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionTimestamp = payload?.actionTimestamp || Date.now();
|
||||
const commandSenderId = payload?.senderId || null;
|
||||
@@ -1748,7 +1858,7 @@ async function routeToContent(action, payload) {
|
||||
|
||||
function getTabVideoState(tabId) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
|
||||
sendMessageToContentTab(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
resolve({ error: chrome.runtime.lastError.message });
|
||||
return;
|
||||
@@ -1760,7 +1870,7 @@ function getTabVideoState(tabId) {
|
||||
|
||||
async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) {
|
||||
let state = await getTabVideoState(tabId);
|
||||
if (!state || state.error) {
|
||||
if (!state || state.error || state.found === false) {
|
||||
const activation = await reactivateCurrentTarget(tabId, { expectedGeneration });
|
||||
if (activation?.status !== 'ok') {
|
||||
return { error: 'Target tab changed before content script recovery completed' };
|
||||
@@ -1924,11 +2034,33 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
|
||||
let needsPageApiSeek = false;
|
||||
let pageApiSeekReady = false;
|
||||
let access = null;
|
||||
let scriptTarget = { tabId };
|
||||
try {
|
||||
access = await inspectTabHostAccess(chrome, tabId);
|
||||
const url = access.url || '';
|
||||
needsPageApiSeek = shouldUsePageApiSeek(url);
|
||||
} catch (_e) {
|
||||
scriptTarget = await resolveMediaScriptTarget(chrome, tabId, url);
|
||||
if (activeTargetActivation?.tabId === tabId) {
|
||||
activeTargetActivation.frameId = Array.isArray(scriptTarget.frameIds)
|
||||
? normalizeFrameId(scriptTarget.frameIds[0])
|
||||
: 0;
|
||||
activeTargetActivation.documentId = null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED) {
|
||||
const requestAdded = requestHostAccess
|
||||
? await addTabHostAccessRequest(chrome, tabId, GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN)
|
||||
: false;
|
||||
const accessError = new Error('KoalaSync needs access to the embedded Google Drive player');
|
||||
accessError.code = HOST_ACCESS_REQUIRED_STATUS;
|
||||
accessError.tabId = tabId;
|
||||
accessError.host = 'youtube.googleapis.com';
|
||||
accessError.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN;
|
||||
accessError.requestAdded = requestAdded === true;
|
||||
accessError.cause = error;
|
||||
throw accessError;
|
||||
}
|
||||
if (error?.code === GOOGLE_DRIVE_PLAYER_AMBIGUOUS) throw error;
|
||||
// Fall through to the generic content script injection.
|
||||
}
|
||||
|
||||
@@ -1952,18 +2084,34 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
|
||||
}
|
||||
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
target: scriptTarget,
|
||||
files: ['page-api-seek-overrides.js']
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
target: scriptTarget,
|
||||
func: setPageApiSeekEnabled,
|
||||
args: [pageApiSeekReady]
|
||||
});
|
||||
return await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
const injectionResults = await chrome.scripting.executeScript({
|
||||
target: scriptTarget,
|
||||
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
|
||||
});
|
||||
const frameId = Array.isArray(scriptTarget.frameIds)
|
||||
? normalizeFrameId(scriptTarget.frameIds[0])
|
||||
: 0;
|
||||
const frameResult = Array.isArray(injectionResults)
|
||||
? injectionResults.find(result => normalizeFrameId(result?.frameId) === frameId)
|
||||
: null;
|
||||
if (activeTargetActivation?.tabId === tabId) {
|
||||
activeTargetActivation.frameId = frameId;
|
||||
activeTargetActivation.documentId = typeof frameResult?.documentId === 'string'
|
||||
? frameResult.documentId
|
||||
: null;
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
documentId: typeof frameResult?.documentId === 'string' ? frameResult.documentId : null
|
||||
};
|
||||
} catch (error) {
|
||||
// A temporary activeTab grant is intentionally allowed to win: even if
|
||||
// permissions.contains() reports false, a successful injection above is
|
||||
@@ -2150,10 +2298,12 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
const activationGeneration = ++targetActivationGeneration;
|
||||
activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId };
|
||||
const previousTabId = normalizeTabId(currentTabId);
|
||||
const previousContentTarget = currentContentTarget();
|
||||
let injectedContentTarget = { frameId: 0, documentId: null };
|
||||
|
||||
try {
|
||||
try {
|
||||
await injectContentScript(selectedTabId, { requestHostAccess });
|
||||
injectedContentTarget = await injectContentScript(selectedTabId, { requestHostAccess });
|
||||
} catch (error) {
|
||||
if (activationGeneration !== targetActivationGeneration) {
|
||||
if (error?.code === HOST_ACCESS_REQUIRED_STATUS
|
||||
@@ -2169,6 +2319,7 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
}
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId) {
|
||||
@@ -2178,6 +2329,8 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
await chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
currentTargetFrameId: 0,
|
||||
currentTargetDocumentId: null,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt: null
|
||||
});
|
||||
@@ -2215,7 +2368,7 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
|
||||
await applyAudioSettingsToTab(selectedTabId);
|
||||
await applyAudioSettingsToTab(selectedTabId, injectedContentTarget);
|
||||
if (activationGeneration !== targetActivationGeneration) {
|
||||
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
|
||||
return { status: 'superseded' };
|
||||
@@ -2230,8 +2383,22 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
if (previousTabId === selectedTabId && !sameContentTarget(previousContentTarget, injectedContentTarget)) {
|
||||
sendMessageToFrame(
|
||||
previousTabId,
|
||||
previousContentTarget.frameId,
|
||||
{ type: 'KOALASYNC_DEACTIVATE' },
|
||||
null,
|
||||
previousContentTarget.documentId
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
currentTabId = selectedTabId;
|
||||
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
|
||||
currentTargetFrameId = normalizeFrameId(injectedContentTarget.frameId);
|
||||
currentTargetDocumentId = typeof injectedContentTarget.documentId === 'string'
|
||||
? injectedContentTarget.documentId
|
||||
: null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId && previousTabId !== selectedTabId) {
|
||||
@@ -2241,6 +2408,8 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
await chrome.storage.session.set({
|
||||
currentTabId,
|
||||
currentTabTitle,
|
||||
currentTargetFrameId,
|
||||
currentTargetDocumentId,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt
|
||||
});
|
||||
@@ -2280,9 +2449,8 @@ async function retryPendingTarget({ expectedRequestId = null, requireGrantedAcce
|
||||
}
|
||||
|
||||
if (requireGrantedAccess) {
|
||||
let access;
|
||||
try {
|
||||
access = await inspectTabHostAccess(chrome, pending.tabId);
|
||||
await chrome.tabs.get(pending.tabId);
|
||||
} catch {
|
||||
await clearPendingTarget({
|
||||
expectedRequestId: pending.requestId,
|
||||
@@ -2290,7 +2458,8 @@ async function retryPendingTarget({ expectedRequestId = null, requireGrantedAcce
|
||||
});
|
||||
return { status: 'invalid_tab' };
|
||||
}
|
||||
if (access.granted !== true || access.originPattern !== pending.originPattern) {
|
||||
const granted = await containsOriginPermission(chrome, pending.originPattern);
|
||||
if (granted !== true) {
|
||||
return { status: 'permission_not_granted' };
|
||||
}
|
||||
pending = await readPendingTarget();
|
||||
@@ -2365,6 +2534,7 @@ if (chrome.tabs?.onRemoved?.addListener) {
|
||||
if (isCurrent) {
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
}
|
||||
@@ -2420,7 +2590,7 @@ if (chrome.tabs?.onRemoved?.addListener) {
|
||||
|
||||
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) {
|
||||
if (!isCurrentTargetIdentity(tabId, targetGeneration)) return;
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
sendMessageToContentTab(tabId, {
|
||||
type: 'SERVER_COMMAND',
|
||||
action,
|
||||
payload,
|
||||
@@ -2495,7 +2665,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
|
||||
function leaveOldRoomIfSwitching(newRoomId) {
|
||||
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_RESET' }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_RESET' }).catch(() => {});
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
@@ -2530,17 +2700,36 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
|
||||
function resetAudioProcessingInTab(tabId) {
|
||||
if (!tabId) return;
|
||||
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
|
||||
sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
|
||||
}
|
||||
|
||||
async function applyAudioSettingsToTab(tabId) {
|
||||
async function applyAudioSettingsToTab(tabId, contentTarget = null) {
|
||||
if (!tabId) return;
|
||||
// Local-only: audioSettings are never read from storage.sync.
|
||||
const data = await chrome.storage.local.get(['audioSettings']);
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
const message = {
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: data.audioSettings
|
||||
}).catch(() => {});
|
||||
};
|
||||
if (contentTarget) {
|
||||
sendMessageToFrame(
|
||||
tabId,
|
||||
contentTarget.frameId,
|
||||
message,
|
||||
null,
|
||||
contentTarget.documentId
|
||||
).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
|
||||
sendMessageToCurrentContent(message).catch(() => {});
|
||||
return;
|
||||
}
|
||||
chrome.tabs.sendMessage(tabId, message).catch(() => {});
|
||||
}
|
||||
|
||||
// --- Extension Message Listeners ---
|
||||
@@ -2556,7 +2745,7 @@ chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return;
|
||||
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
|
||||
invalidateChatSession();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
});
|
||||
|
||||
async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
@@ -2571,7 +2760,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
@@ -2654,8 +2843,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
chatEnabled: settings.chatEnabled
|
||||
});
|
||||
} else if (message.type === 'GET_CHAT_CONTEXT') {
|
||||
const senderTabId = sender.tab?.id;
|
||||
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
|
||||
if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) {
|
||||
sendResponse({ supported: false, hasKey: false });
|
||||
return;
|
||||
}
|
||||
@@ -2691,8 +2879,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
});
|
||||
} else if (message.type === 'CHAT_SEND') {
|
||||
const senderTabId = sender.tab?.id;
|
||||
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
|
||||
if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'invalid_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -2783,7 +2970,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// "Solo" to the host (stale-badge split-brain).
|
||||
sendResponse({ controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) });
|
||||
} else if (message.type === 'REQUEST_HOST_SYNC') {
|
||||
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
|
||||
if (sender.tab && !isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab', target: null });
|
||||
return;
|
||||
}
|
||||
@@ -2810,7 +2997,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'HCM_DESYNC_STATE') {
|
||||
// content.js tells us whether the local user chose to watch on their own.
|
||||
// Only accept from the currently selected tab.
|
||||
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
|
||||
if (sender.tab && !isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -2838,10 +3025,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
|
||||
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
|
||||
broadcastControlMode();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
|
||||
@@ -2928,7 +3116,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
@@ -2987,12 +3175,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ error: 'No tabId provided' });
|
||||
return;
|
||||
}
|
||||
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
sendResponse({ error: chrome.runtime.lastError.message });
|
||||
} else {
|
||||
sendResponse(res);
|
||||
}
|
||||
getReadyTabVideoState(tabId).then(res => {
|
||||
sendResponse(res);
|
||||
}).catch(error => {
|
||||
sendResponse({ error: error.message });
|
||||
});
|
||||
} else if (message.type === 'DEV_SIMULATE_REMOTE_SEEK') {
|
||||
if (!(await devRemoteToolsAllowed())) {
|
||||
@@ -3028,11 +3214,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
HOST_ONLY_GATED_ACTIONS.includes(message.action)) {
|
||||
addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn');
|
||||
if (sender.tab && sender.tab.id) {
|
||||
chrome.tabs.sendMessage(sender.tab.id, {
|
||||
sendMessageToFrame(sender.tab.id, sender.frameId, {
|
||||
type: 'HOST_BLOCKED',
|
||||
action: message.action,
|
||||
target: getHostSyncTarget()
|
||||
}).catch(() => {});
|
||||
}, null, sender.documentId).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'blocked_host_only' });
|
||||
return;
|
||||
@@ -3140,9 +3326,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
};
|
||||
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
if (!isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -3161,7 +3345,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'FORCE_SYNC_ACK') {
|
||||
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
|
||||
if (sender.tab && !isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -3189,7 +3373,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CMD_ACK') {
|
||||
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
|
||||
if (sender.tab && !isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -3209,9 +3393,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'HEARTBEAT') {
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
if (!isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -3286,6 +3468,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId) {
|
||||
@@ -3296,6 +3479,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
await chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
currentTargetFrameId: 0,
|
||||
currentTargetDocumentId: null,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt: null
|
||||
});
|
||||
@@ -3323,8 +3508,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'EPISODE_CHANGED') {
|
||||
// Content script detected an episode transition
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
if (!isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -3409,10 +3593,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// Tell content script to pause the video and start polling
|
||||
// (This is the only place we pause — after confirming the feature is enabled)
|
||||
if (sender.tab && sender.tab.id) {
|
||||
chrome.tabs.sendMessage(sender.tab.id, {
|
||||
sendMessageToFrame(sender.tab.id, sender.frameId, {
|
||||
type: 'PAUSE_FOR_LOBBY',
|
||||
expectedTitle: lobbyTitle
|
||||
}).catch(() => {});
|
||||
}, null, sender.documentId).catch(() => {});
|
||||
}
|
||||
|
||||
// Broadcast to room
|
||||
@@ -3427,8 +3611,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'lobby_created' });
|
||||
} else if (message.type === 'EPISODE_READY_LOCAL') {
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
if (!isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -3467,13 +3650,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
});
|
||||
}
|
||||
if (currentRoom && currentTabId) {
|
||||
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
||||
sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'DRIVE_FRAME_VISIBILITY') {
|
||||
if (!isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_stale_frame' });
|
||||
return;
|
||||
}
|
||||
if (message.visible !== false) {
|
||||
sendResponse({ status: 'ok' });
|
||||
return;
|
||||
}
|
||||
const tabId = normalizeTabId(sender.tab?.id);
|
||||
const expectedGeneration = targetActivationGeneration;
|
||||
const activation = tabId === null
|
||||
? null
|
||||
: await reactivateCurrentTarget(tabId, { expectedGeneration });
|
||||
sendResponse(activation || { status: 'invalid_tab' });
|
||||
} else if (message.type === 'CONTENT_BOOT') {
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
if (!isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
@@ -3500,7 +3697,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
initTabManager({
|
||||
getCurrentTabId: () => currentTabId,
|
||||
reactivateCurrentTarget,
|
||||
ensureState
|
||||
ensureState,
|
||||
sendToCurrentContent: sendMessageToCurrentContent
|
||||
});
|
||||
|
||||
// Initial Connect — only if user has an active room configuration
|
||||
|
||||
+106
-13
@@ -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,10 +21,24 @@
|
||||
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';
|
||||
|
||||
@@ -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) {
|
||||
@@ -328,10 +382,19 @@
|
||||
const hasKey = !!context?.hasKey;
|
||||
const connected = !!context?.connected;
|
||||
context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
|
||||
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages();
|
||||
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();
|
||||
}
|
||||
@@ -451,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();
|
||||
});
|
||||
@@ -481,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();
|
||||
}
|
||||
|
||||
@@ -516,16 +595,30 @@
|
||||
systemTheme.addEventListener('change', handleSystemTheme);
|
||||
chrome.storage.onChanged.addListener(handleStorage);
|
||||
chrome.runtime.onMessage.addListener(handleRuntime);
|
||||
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette'], data => {
|
||||
if (data[storageKey] && typeof data[storageKey] === 'object') layout = { ...layout, ...data[storageKey] };
|
||||
if (!['left', 'right', 'detached'].includes(layout.mode)) layout.mode = 'right';
|
||||
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode'], data => {
|
||||
const storedLayout = data[storageKey];
|
||||
if (storedLayout && typeof storedLayout === 'object') {
|
||||
layout = { ...layout, ...storedLayout };
|
||||
layout.customWidth = Number(storedLayout.customWidth) || Number(storedLayout.width) || DEFAULT_WIDTH;
|
||||
layout.customHeight = Number(storedLayout.customHeight) || Number(storedLayout.height) || DEFAULT_HEIGHT;
|
||||
}
|
||||
chatPosition = normalizePosition(data.chatPosition);
|
||||
chatSize = normalizeSize(data.chatSize);
|
||||
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
|
||||
layout.mode = chatPosition;
|
||||
if (layout.mode === 'detached') layout.detachedInitialized = true;
|
||||
const preset = SIZE_PRESETS[chatSize];
|
||||
if (preset) {
|
||||
layout.width = preset.width;
|
||||
layout.height = preset.height;
|
||||
}
|
||||
themeMode = data.themeMode || 'system';
|
||||
themePalette = data.themePalette || 'eucalyptus';
|
||||
preferencesLoaded = true;
|
||||
applyTheme();
|
||||
applyLayout();
|
||||
refresh();
|
||||
});
|
||||
|
||||
window.koalaSyncChatOverlay = { refresh, destroy };
|
||||
refresh();
|
||||
})();
|
||||
|
||||
+70
-25
@@ -6,13 +6,15 @@
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
(function() {
|
||||
|
||||
|
||||
(function() {
|
||||
|
||||
// Injection Guard: Check if already injected AND context is valid
|
||||
|
||||
try {
|
||||
try {
|
||||
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
window.koalaSyncTargetActive = true;
|
||||
return;
|
||||
}
|
||||
@@ -86,9 +88,34 @@
|
||||
if (_suppressTimers[state]) {
|
||||
|
||||
clearTimeout(_suppressTimers[state]);
|
||||
|
||||
if (_suppressTimers[state]) {
|
||||
|
||||
|
||||
delete _suppressTimers[state];
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Seek Relay Filtering ---
|
||||
|
||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||
|
||||
// from being relayed to peers as user-initiated seeks.
|
||||
|
||||
const MIN_SEEK_DELTA = 2.0;
|
||||
|
||||
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
|
||||
|
||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||
|
||||
let expectedSeekTime = null; // strictly track programmatic seeks
|
||||
|
||||
|
||||
|
||||
const PAGE_API_SEEK_BRIDGE = 1;
|
||||
// Accurate Disney+ playhead pushed by the MAIN-world page-API bridge
|
||||
// (background.js installPageApiSeekBridge). The isolated content world
|
||||
// can't read the page's media player directly.
|
||||
let disneyPageApiTime = null;
|
||||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||||
@@ -851,11 +878,17 @@
|
||||
document.addEventListener('DOMContentLoaded', retry, { once: true });
|
||||
|
||||
} else {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', retry, { once: true });
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
setTimeout(retry, 50);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
@@ -864,9 +897,11 @@
|
||||
|
||||
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
|
||||
|
||||
}
|
||||
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
b.addEventListener('click', hcmExitDesync);
|
||||
|
||||
root.appendChild(b);
|
||||
|
||||
document.body.appendChild(host);
|
||||
|
||||
hcmBadgeHost = host;
|
||||
|
||||
@@ -951,8 +986,14 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
|
||||
|
||||
// Multiple videos found → pick the best one
|
||||
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
|
||||
|
||||
@@ -1136,9 +1177,10 @@
|
||||
|
||||
|
||||
|
||||
const ctx = initAudioContext();
|
||||
|
||||
if (!ctx) return null;
|
||||
src.connect(dryGain);
|
||||
|
||||
dryGain.connect(ctx.destination);
|
||||
|
||||
src.connect(compressor);
|
||||
|
||||
compressor.connect(compGain);
|
||||
@@ -1271,8 +1313,9 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
chain.compressor.release.value = params.release ?? 0.300;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
|
||||
@@ -1308,8 +1351,9 @@
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
// Extract a canonical episode identifier from a title string.
|
||||
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1552,8 +1596,9 @@
|
||||
matches() { return isGoogleDrivePlayerFrame(); },
|
||||
playPauseButtonSelector: '.ytp-play-button'
|
||||
},
|
||||
lobbyPollTimer = null;
|
||||
|
||||
{
|
||||
name: 'twitch-player-buttons',
|
||||
urls: ['twitch.tv'],
|
||||
playPauseButtonSelector: '[data-a-target="player-play-pause-button"]'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -150,6 +150,18 @@ export function requestOriginPermission(chromeApi, originPattern) {
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
export function containsOriginPermission(chromeApi, originPattern) {
|
||||
if (typeof originPattern !== 'string' || !originPattern) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return callBooleanPermissionMethod(
|
||||
chromeApi,
|
||||
'contains',
|
||||
{ origins: [originPattern] },
|
||||
{ timeoutMs: 1000 }
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
export function isHostAccessError(error) {
|
||||
const message = typeof error?.message === 'string' ? error.message : String(error || '');
|
||||
return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message);
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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": "ルームチャットを開く",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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": "방 채팅 열기",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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": "Открыть чат комнаты",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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ç",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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": "Відкрити чат кімнати",
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
"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": "打开房间聊天",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"default_locale": "en",
|
||||
"name": "__MSG_appName__",
|
||||
"short_name": "KoalaSync",
|
||||
"version": "2.6.4",
|
||||
"version": "3.0.0",
|
||||
"description": "__MSG_appDesc__",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
const GOOGLE_DRIVE_HOST = 'drive.google.com';
|
||||
const GOOGLE_DRIVE_PLAYER_HOST = 'youtube.googleapis.com';
|
||||
export const GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN = 'https://youtube.googleapis.com/*';
|
||||
export const GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED = 'google_drive_player_access_required';
|
||||
export const GOOGLE_DRIVE_PLAYER_AMBIGUOUS = 'google_drive_player_ambiguous';
|
||||
|
||||
export function isGoogleDriveUrl(value) {
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() === GOOGLE_DRIVE_HOST;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectMediaFrame() {
|
||||
const videos = Array.from(document.querySelectorAll('video'));
|
||||
const drivePlayerIframes = Array.from(document.querySelectorAll('iframe')).filter((frame) => {
|
||||
try {
|
||||
const url = new URL(frame.src);
|
||||
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com') return false;
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
return origin === 'https://drive.google.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
let bestVideoScore = 0;
|
||||
|
||||
for (const video of videos) {
|
||||
const width = video.videoWidth || video.offsetWidth || 0;
|
||||
const height = video.videoHeight || video.offsetHeight || 0;
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
bestVideoScore = Math.max(bestVideoScore, (width * height) + (duration * 100));
|
||||
}
|
||||
|
||||
return {
|
||||
href: window.location.href,
|
||||
videoCount: videos.length,
|
||||
videoScore: bestVideoScore,
|
||||
frameArea: Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight),
|
||||
drivePlayerIframeCount: drivePlayerIframes.length,
|
||||
parentFrameVisible: window.__koalaParentFrameVisibility?.visible ?? null,
|
||||
parentFrameArea: window.__koalaParentFrameVisibility?.area ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function installParentFrameVisibilityProbe(token) {
|
||||
const handler = (event) => {
|
||||
if (event.source !== window.parent
|
||||
|| event.data?.type !== 'KOALASYNC_FRAME_VISIBILITY'
|
||||
|| event.data?.token !== token) {
|
||||
return;
|
||||
}
|
||||
window.__koalaParentFrameVisibility = {
|
||||
visible: event.data.visible === true,
|
||||
area: Number.isFinite(event.data.area) ? event.data.area : 0
|
||||
};
|
||||
window.removeEventListener('message', handler);
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
}
|
||||
|
||||
export async function dispatchParentFrameVisibilityProbe(token) {
|
||||
let count = 0;
|
||||
for (const frame of document.querySelectorAll('iframe')) {
|
||||
try {
|
||||
const url = new URL(frame.src);
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com'
|
||||
|| origin !== 'https://drive.google.com') {
|
||||
continue;
|
||||
}
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(frame);
|
||||
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||
const intersectsViewport = rect.bottom > 0
|
||||
&& rect.right > 0
|
||||
&& rect.top < window.innerHeight
|
||||
&& rect.left < window.innerWidth;
|
||||
const browserReportsVisible = typeof frame.checkVisibility === 'function'
|
||||
? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
||||
: true;
|
||||
const visible = area > 0
|
||||
&& intersectsViewport
|
||||
&& browserReportsVisible
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity) !== 0;
|
||||
frame.contentWindow?.postMessage({
|
||||
type: 'KOALASYNC_FRAME_VISIBILITY',
|
||||
token,
|
||||
visible,
|
||||
area
|
||||
}, 'https://youtube.googleapis.com');
|
||||
count++;
|
||||
} catch {
|
||||
// Ignore unrelated or not-yet-initialized frames.
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return count;
|
||||
}
|
||||
|
||||
function isGoogleDrivePlayerFrameUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname.toLowerCase() !== GOOGLE_DRIVE_PLAYER_HOST || url.pathname !== '/embed/') {
|
||||
return false;
|
||||
}
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
return origin === 'https://drive.google.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createGoogleDrivePlayerAccessError() {
|
||||
const error = new Error('Google Drive player frame access is required');
|
||||
error.code = GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED;
|
||||
error.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN;
|
||||
return error;
|
||||
}
|
||||
|
||||
function createGoogleDrivePlayerAmbiguousError() {
|
||||
const error = new Error('Google Drive player frame could not be identified safely');
|
||||
error.code = GOOGLE_DRIVE_PLAYER_AMBIGUOUS;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function selectGoogleDrivePlayerFrame(injectionResults) {
|
||||
let candidates = (Array.isArray(injectionResults) ? injectionResults : [])
|
||||
.filter(entry => Number.isInteger(entry?.frameId) && entry?.result)
|
||||
.filter(entry => isGoogleDrivePlayerFrameUrl(entry.result.href));
|
||||
|
||||
if (candidates.length > 1) {
|
||||
const confirmedVisible = candidates.filter(entry => entry.result.parentFrameVisible === true);
|
||||
if (confirmedVisible.length > 0) {
|
||||
candidates = confirmedVisible;
|
||||
} else {
|
||||
const notConfirmedHidden = candidates.filter(entry =>
|
||||
entry.result.parentFrameVisible !== false
|
||||
);
|
||||
if (notConfirmedHidden.length !== 1) return null;
|
||||
candidates = notConfirmedHidden;
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
const visibilityRank = result => result.parentFrameVisible === true
|
||||
? 2
|
||||
: result.parentFrameVisible === false
|
||||
? 0
|
||||
: 1;
|
||||
const leftVisible = visibilityRank(left.result);
|
||||
const rightVisible = visibilityRank(right.result);
|
||||
if (leftVisible !== rightVisible) return rightVisible - leftVisible;
|
||||
const leftParentArea = Number.isFinite(left.result.parentFrameArea)
|
||||
? left.result.parentFrameArea
|
||||
: left.result.frameArea;
|
||||
const rightParentArea = Number.isFinite(right.result.parentFrameArea)
|
||||
? right.result.parentFrameArea
|
||||
: right.result.frameArea;
|
||||
if (leftParentArea !== rightParentArea) return rightParentArea - leftParentArea;
|
||||
const leftHasVideo = left.result.videoCount > 0 ? 1 : 0;
|
||||
const rightHasVideo = right.result.videoCount > 0 ? 1 : 0;
|
||||
if (leftHasVideo !== rightHasVideo) return rightHasVideo - leftHasVideo;
|
||||
if (left.result.videoScore !== right.result.videoScore) {
|
||||
return right.result.videoScore - left.result.videoScore;
|
||||
}
|
||||
return right.result.frameArea - left.result.frameArea;
|
||||
});
|
||||
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
export async function resolveMediaScriptTarget(chromeApi, tabId, tabUrl, {
|
||||
attempts = 8,
|
||||
retryDelayMs = 200
|
||||
} = {}) {
|
||||
const topFrameTarget = { tabId };
|
||||
if (!isGoogleDriveUrl(tabUrl)) return topFrameTarget;
|
||||
|
||||
let fallbackFrame = null;
|
||||
let drivePlayerIframeDetected = false;
|
||||
let ambiguousPlayerFramesDetected = false;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
let results;
|
||||
try {
|
||||
results = await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
} catch {
|
||||
let topFrameResult = null;
|
||||
try {
|
||||
const topResults = await chromeApi.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
topFrameResult = Array.isArray(topResults) ? topResults[0]?.result : null;
|
||||
} catch {
|
||||
// The generic injection path below remains the final source of truth.
|
||||
}
|
||||
if (topFrameResult?.drivePlayerIframeCount > 0) {
|
||||
throw createGoogleDrivePlayerAccessError();
|
||||
}
|
||||
return topFrameTarget;
|
||||
}
|
||||
|
||||
const topFrame = results.find(entry => entry?.frameId === 0);
|
||||
if (topFrame?.result?.drivePlayerIframeCount > 0) {
|
||||
drivePlayerIframeDetected = true;
|
||||
}
|
||||
const playerFrameCount = results.filter(entry =>
|
||||
isGoogleDrivePlayerFrameUrl(entry?.result?.href)
|
||||
).length;
|
||||
if (playerFrameCount > 1) {
|
||||
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
|
||||
try {
|
||||
await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: installParentFrameVisibilityProbe,
|
||||
args: [token]
|
||||
});
|
||||
await chromeApi.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: dispatchParentFrameVisibilityProbe,
|
||||
args: [token]
|
||||
});
|
||||
results = await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
} catch {
|
||||
// Ambiguous frames are retried below; never guess a stale player.
|
||||
}
|
||||
}
|
||||
const selectedFrame = selectGoogleDrivePlayerFrame(results);
|
||||
if (!selectedFrame && playerFrameCount > 1) {
|
||||
ambiguousPlayerFramesDetected = true;
|
||||
}
|
||||
if (selectedFrame?.result?.videoCount > 0) {
|
||||
return { tabId, frameIds: [selectedFrame.frameId] };
|
||||
}
|
||||
if (selectedFrame) fallbackFrame = selectedFrame;
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackFrame && ambiguousPlayerFramesDetected) {
|
||||
throw createGoogleDrivePlayerAmbiguousError();
|
||||
}
|
||||
if (!fallbackFrame && drivePlayerIframeDetected) {
|
||||
throw createGoogleDrivePlayerAccessError();
|
||||
}
|
||||
|
||||
return fallbackFrame
|
||||
? { tabId, frameIds: [fallbackFrame.frameId] }
|
||||
: topFrameTarget;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED,
|
||||
GOOGLE_DRIVE_PLAYER_AMBIGUOUS,
|
||||
isGoogleDriveUrl,
|
||||
resolveMediaScriptTarget,
|
||||
selectGoogleDrivePlayerFrame
|
||||
} from './media-frame-target.js';
|
||||
|
||||
const drivePlayerUrl = 'https://youtube.googleapis.com/embed/?enablejsapi=1&origin=https%3A%2F%2Fdrive.google.com';
|
||||
|
||||
describe('Google Drive media-frame targeting', () => {
|
||||
it('recognizes Drive tabs without treating other Google pages as Drive', () => {
|
||||
expect(isGoogleDriveUrl('https://drive.google.com/drive/u/1/search?q=mp4')).toBe(true);
|
||||
expect(isGoogleDriveUrl('https://docs.google.com/document/d/1/edit')).toBe(false);
|
||||
});
|
||||
|
||||
it('selects the visible frame containing the real video', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000, parentFrameVisible: true } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('never lets a hidden loaded player outrank the visible Drive player', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000, parentFrameVisible: true, parentFrameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000, parentFrameVisible: false, parentFrameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('refuses to guess when parent visibility is unavailable', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores unrelated video frames inside a Drive tab', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 3, result: { href: 'https://example.com/embed', videoCount: 1, videoScore: 3000000, frameArea: 1200000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('falls back to the visible Drive player frame while its video is loading', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000, parentFrameVisible: true } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('reports ambiguity instead of selecting a stale frame after retries', async () => {
|
||||
const ambiguousResults = [
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
|
||||
];
|
||||
const executeScript = vi.fn().mockResolvedValue(ambiguousResults);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_AMBIGUOUS });
|
||||
});
|
||||
|
||||
it('probes all frames and returns only the frame with the Drive video', async () => {
|
||||
const executeScript = vi.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000 } }
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ retryDelayMs: 0 }
|
||||
)).resolves.toEqual({ tabId: 42, frameIds: [4] });
|
||||
|
||||
expect(executeScript).toHaveBeenCalledWith({
|
||||
target: { tabId: 42, allFrames: true },
|
||||
func: expect.any(Function)
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing access when Drive exposes a player iframe but the frame cannot be inspected', async () => {
|
||||
const executeScript = vi.fn().mockResolvedValue([
|
||||
{
|
||||
frameId: 0,
|
||||
result: {
|
||||
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
videoCount: 0,
|
||||
videoScore: 0,
|
||||
frameArea: 900000,
|
||||
drivePlayerIframeCount: 1
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
|
||||
});
|
||||
|
||||
it('reports missing access when the all-frame probe itself is rejected', async () => {
|
||||
const executeScript = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('Cannot access contents of the frame'))
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
frameId: 0,
|
||||
result: {
|
||||
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
videoCount: 0,
|
||||
videoScore: 0,
|
||||
frameArea: 900000,
|
||||
drivePlayerIframeCount: 1
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
export function initTabManager({
|
||||
getCurrentTabId,
|
||||
reactivateCurrentTarget,
|
||||
ensureState
|
||||
ensureState,
|
||||
sendToCurrentContent
|
||||
}) {
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
@@ -9,7 +10,7 @@ export function initTabManager({
|
||||
const tabId = getCurrentTabId();
|
||||
if (!tabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
sendToCurrentContent({
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: changes.audioSettings.newValue
|
||||
}).catch(() => {});
|
||||
|
||||
+52
-9
@@ -1215,6 +1215,12 @@
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
[data-chat-setting] {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
[data-chat-setting].is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.settings-issue-link {
|
||||
margin: 8px 0 2px;
|
||||
padding-top: 9px;
|
||||
@@ -1323,7 +1329,7 @@
|
||||
}
|
||||
@keyframes floatHint {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -1446,7 +1452,7 @@
|
||||
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
|
||||
</select>
|
||||
<!-- Hint Tooltip -->
|
||||
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px color-mix(in oklch, var(--accent), transparent 60%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
|
||||
<div id="targetTabHint" style="display: none; position: absolute; top: -8px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 2px 6px color-mix(in oklch, var(--accent), transparent 82%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
|
||||
Select your video here!
|
||||
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
|
||||
</div>
|
||||
@@ -1615,6 +1621,50 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="form-group" name="settings-accordion">
|
||||
<summary title="Configure encrypted room chat appearance and placement." data-i18n="CHAT_TITLE" data-i18n-title="LABEL_SETTINGS_GROUP_CHAT_TOOLTIP">Room Chat</summary>
|
||||
<div class="details-content">
|
||||
<p class="settings-note" data-i18n="CHAT_SETTINGS_NOTE">Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.</p>
|
||||
<div class="settings-row">
|
||||
<label for="chatEnabled" title="Show encrypted room chat on the selected video tab." data-i18n="LABEL_CHAT_ENABLED" data-i18n-title="LABEL_CHAT_ENABLED_TOOLTIP">Enable Room Chat</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="chatEnabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-row" data-chat-setting>
|
||||
<label for="chatPosition" title="Choose where chat is attached to the selected player." data-i18n="LABEL_CHAT_POSITION" data-i18n-title="LABEL_CHAT_POSITION_TOOLTIP">Position</label>
|
||||
<span class="settings-select">
|
||||
<select id="chatPosition" class="settings-control">
|
||||
<option value="right" data-i18n="CHAT_DOCK_RIGHT">Dock chat right</option>
|
||||
<option value="left" data-i18n="CHAT_DOCK_LEFT">Dock chat left</option>
|
||||
<option value="detached" data-i18n="CHAT_DETACHED">Detach chat</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="settings-row" data-chat-setting>
|
||||
<label for="chatSize" title="Choose the default size of the chat overlay." data-i18n="LABEL_CHAT_SIZE" data-i18n-title="LABEL_CHAT_SIZE_TOOLTIP">Overlay size</label>
|
||||
<span class="settings-select">
|
||||
<select id="chatSize" class="settings-control">
|
||||
<option value="compact" data-i18n="OPTION_CHAT_SIZE_COMPACT">Compact</option>
|
||||
<option value="standard" data-i18n="OPTION_CHAT_SIZE_STANDARD">Standard</option>
|
||||
<option value="large" data-i18n="OPTION_CHAT_SIZE_LARGE">Large</option>
|
||||
<option value="custom" data-i18n="OPTION_CHAT_SIZE_CUSTOM">Last custom size</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="settings-row" data-chat-setting>
|
||||
<label for="chatStartMode" title="Choose whether chat starts as a floating bubble or an open overlay." data-i18n="LABEL_CHAT_START_MODE" data-i18n-title="LABEL_CHAT_START_MODE_TOOLTIP">Start as</label>
|
||||
<span class="settings-select">
|
||||
<select id="chatStartMode" class="settings-control">
|
||||
<option value="bubble" data-i18n="OPTION_CHAT_START_BUBBLE">Floating bubble</option>
|
||||
<option value="open" data-i18n="OPTION_CHAT_START_OPEN">Open overlay</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="form-group" name="settings-accordion">
|
||||
<summary title="Customize playback, notifications, and audio preferences." data-i18n="LABEL_SETTINGS_GROUP_SYNC" data-i18n-title="LABEL_SETTINGS_GROUP_SYNC_TOOLTIP">Playback & Sync</summary>
|
||||
<div class="details-content">
|
||||
@@ -1625,13 +1675,6 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<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">
|
||||
<label for="filterNoise" title="Filters out non-video tabs and unrelated domains to keep the list clean" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label>
|
||||
<label class="toggle-switch">
|
||||
|
||||
+44
-16
@@ -1,11 +1,12 @@
|
||||
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
|
||||
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { BLACKLIST_ALLOWLIST_DOMAINS, BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
||||
import { normalizeRoomId } from './chat-session.js';
|
||||
import './shared/invite-links.js';
|
||||
import { normalizeTabId, requestOriginPermission } from './host-access.js';
|
||||
import { isTabUrlFiltered } from './tab-filter.js';
|
||||
|
||||
let pendingInviteRoomId = '';
|
||||
let pendingInviteChatKey = '';
|
||||
@@ -69,6 +70,9 @@ const elements = {
|
||||
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'),
|
||||
@@ -338,7 +342,7 @@ function setRoomRefreshCooldown() {
|
||||
async function init() {
|
||||
// Local-only by design — settings and room credentials never come from
|
||||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', '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) {
|
||||
@@ -370,6 +374,10 @@ async function init() {
|
||||
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);
|
||||
@@ -1150,18 +1158,9 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
const filteredTabs = tabs.filter(tab => {
|
||||
if (!tab.url || tab.url.startsWith('chrome://')) return false;
|
||||
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
|
||||
const urlStr = tab.url.toLowerCase();
|
||||
if (BLACKLIST_DOMAINS.some(d => {
|
||||
const domain = d.toLowerCase();
|
||||
try {
|
||||
const hostname = new URL(tab.url).hostname.toLowerCase();
|
||||
if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
|
||||
if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
|
||||
} catch {
|
||||
/* ignore invalid URLs */
|
||||
}
|
||||
return urlStr.includes(domain);
|
||||
})) return false;
|
||||
if (isTabUrlFiltered(tab.url, BLACKLIST_DOMAINS, BLACKLIST_ALLOWLIST_DOMAINS)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -1458,11 +1457,40 @@ 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', () => {
|
||||
@@ -2037,7 +2065,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (retryResponse) => {
|
||||
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
@@ -2045,7 +2073,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
sendForceSync(retryResponse.currentTime);
|
||||
});
|
||||
};
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (response) => {
|
||||
if (Number.isFinite(response?.currentTime)) {
|
||||
sendForceSync(response.currentTime);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
function hostnameMatchesDomain(hostname, domain) {
|
||||
const normalizedHostname = String(hostname || '').toLowerCase();
|
||||
const normalizedDomain = String(domain || '').toLowerCase();
|
||||
return normalizedDomain
|
||||
&& (normalizedHostname === normalizedDomain
|
||||
|| normalizedHostname.endsWith(`.${normalizedDomain}`));
|
||||
}
|
||||
|
||||
export function isTabUrlFiltered(url, blacklistDomains, allowlistDomains = []) {
|
||||
const urlString = String(url || '');
|
||||
|
||||
try {
|
||||
const hostname = new URL(urlString).hostname.toLowerCase();
|
||||
if (allowlistDomains.some(domain => hostnameMatchesDomain(hostname, domain))) {
|
||||
return false;
|
||||
}
|
||||
return blacklistDomains.some(domain => hostnameMatchesDomain(hostname, domain));
|
||||
} catch {
|
||||
const normalizedUrl = urlString.toLowerCase();
|
||||
return blacklistDomains.some(domain => normalizedUrl.includes(String(domain).toLowerCase()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTabUrlFiltered } from './tab-filter.js';
|
||||
|
||||
const blacklist = ['google.com', 'mail.google.com'];
|
||||
const allowlist = ['drive.google.com'];
|
||||
|
||||
describe('tab URL filtering', () => {
|
||||
it('keeps Google Drive selectable despite the google.com parent-domain rule', () => {
|
||||
expect(isTabUrlFiltered(
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
blacklist,
|
||||
allowlist
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('continues to filter other Google subdomains', () => {
|
||||
expect(isTabUrlFiltered('https://mail.google.com/mail/u/0/', blacklist, allowlist)).toBe(true);
|
||||
expect(isTabUrlFiltered('https://www.google.com/search?q=video', blacklist, allowlist)).toBe(true);
|
||||
});
|
||||
});
|
||||
Generated
+611
-627
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.6.4",
|
||||
"version": "3.0.0",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -18,18 +18,18 @@
|
||||
"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": "^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.35.3",
|
||||
"svgo": "^4.0.2",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "5.0.8",
|
||||
|
||||
@@ -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');
|
||||
@@ -98,14 +98,15 @@ function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null
|
||||
].join('\n'))({ location: { hostname } }, document);
|
||||
}
|
||||
|
||||
function loadPlayerFixFns(hostname) {
|
||||
function loadPlayerFixFns(hostname, href = `https://${hostname}/`) {
|
||||
return Function('window', [
|
||||
extractFunction('hostMatchesUrl', source),
|
||||
extractFunction('matchesPlayerUrls', source),
|
||||
extractFunction('isGoogleDrivePlayerFrame', source),
|
||||
extractFunction('getPlayerActionFixes', source),
|
||||
extractFunction('getActivePlayerActionFix', source),
|
||||
'return { getPlayerActionFixes, getActivePlayerActionFix };'
|
||||
].join('\n'))({ location: { hostname } });
|
||||
'return { isGoogleDrivePlayerFrame, getPlayerActionFixes, getActivePlayerActionFix };'
|
||||
].join('\n'))({ location: { hostname, href } });
|
||||
}
|
||||
|
||||
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
|
||||
@@ -147,6 +148,28 @@ const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
|
||||
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
|
||||
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
|
||||
|
||||
const googleDriveFixFns = loadPlayerFixFns(
|
||||
'youtube.googleapis.com',
|
||||
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
|
||||
);
|
||||
assert.equal(googleDriveFixFns.getActivePlayerActionFix().name, 'google-drive-player-buttons');
|
||||
assert.deepEqual(googleDriveFixFns.getActivePlayerActionFix().urls, ['youtube.googleapis.com']);
|
||||
const unrelatedGoogleEmbedFixFns = loadPlayerFixFns(
|
||||
'youtube.googleapis.com',
|
||||
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fexample.com'
|
||||
);
|
||||
assert.equal(unrelatedGoogleEmbedFixFns.getActivePlayerActionFix(), null);
|
||||
const googleDrivePlayerFns = Function('window', [
|
||||
extractFunction('isGoogleDrivePlayerFrame', source),
|
||||
'return { isGoogleDrivePlayerFrame };'
|
||||
].join('\n'))({
|
||||
location: {
|
||||
hostname: 'youtube.googleapis.com',
|
||||
href: 'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
|
||||
}
|
||||
});
|
||||
assert.equal(googleDrivePlayerFns.isGoogleDrivePlayerFrame(), true);
|
||||
|
||||
const genericFixFns = loadPlayerFixFns('example.com');
|
||||
assert.equal(genericFixFns.getActivePlayerActionFix(), null);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cwd } from 'node:process';
|
||||
import {
|
||||
HOST_ACCESS_REQUIRED_STATUS,
|
||||
addTabHostAccessRequest,
|
||||
containsOriginPermission,
|
||||
describeTabUrl,
|
||||
inspectTabHostAccess,
|
||||
isHostAccessError,
|
||||
@@ -122,6 +123,17 @@ const callbackPermissionChrome = {
|
||||
};
|
||||
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
|
||||
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
|
||||
let containedOriginRequest = null;
|
||||
assert.equal(await containsOriginPermission({
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containedOriginRequest = request;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}, 'https://youtube.googleapis.com/*'), true);
|
||||
assert.deepEqual(containedOriginRequest, { origins: ['https://youtube.googleapis.com/*'] });
|
||||
assert.equal(await containsOriginPermission({ permissions: {} }, 'https://youtube.googleapis.com/*'), null);
|
||||
|
||||
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+46
-37
@@ -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": {
|
||||
@@ -360,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"
|
||||
@@ -539,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"
|
||||
@@ -571,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"
|
||||
@@ -617,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": {
|
||||
@@ -752,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"
|
||||
@@ -767,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": {
|
||||
@@ -864,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"
|
||||
},
|
||||
@@ -964,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",
|
||||
@@ -1069,9 +1078,9 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -1099,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"
|
||||
|
||||
@@ -178,3 +178,9 @@ export const BLACKLIST_DOMAINS = [
|
||||
'lichess.org',
|
||||
'skribbl.io'
|
||||
];
|
||||
|
||||
// Explicit exceptions for useful media pages whose parent domain is filtered.
|
||||
// Google Drive is otherwise matched indirectly by the broad `google.com` entry.
|
||||
export const BLACKLIST_ALLOWLIST_DOMAINS = [
|
||||
'drive.google.com'
|
||||
];
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "2.6.4";
|
||||
export const APP_VERSION = "3.0.0";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ Compatibility depends on each website's player implementation and can change whe
|
||||
|
||||
## Technical information
|
||||
|
||||
- Current website release: 2.6.4
|
||||
- Current website release: 3.0.0
|
||||
- License: MIT
|
||||
- Extension runtime: dependency-free browser extension code
|
||||
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"description": "{{SCHEMA_APP_DESC}}",
|
||||
"softwareVersion": "2.6.4",
|
||||
"softwareVersion": "3.0.0",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.6.4",
|
||||
"date": "2026-07-16T09:51:25Z"
|
||||
"version": "3.0.0",
|
||||
"date": "2026-07-26T00:25:06Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user