Merge remote-tracking branch 'origin/main' into feature/textchat

# Conflicts:
#	extension/background.js
#	extension/popup.js
This commit is contained in:
Timo
2026-07-19 20:32:24 +02:00
67 changed files with 2027 additions and 230 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
"message": "Erstelle private Watch Partys und synchronisiere Videos mit Freunden auf YouTube, Netflix, Jellyfin, Emby und fast jeder Website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
"message": "Create private watch parties and sync videos with friends on YouTube, Netflix, Jellyfin, Emby, and almost any HTML5 website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5."
"message": "Crea fiestas privadas y sincroniza vídeos con amigos en YouTube, Netflix, Jellyfin, Emby y casi cualquier sitio HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
"message": "Créez des watch parties privées et regardez en synchro entre amis sur YouTube, Netflix, Jellyfin, Emby et presque tout site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
"message": "Crea watch party private e sincronizza i video con gli amici su YouTube, Netflix, Jellyfin, Emby e quasi ogni sito HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
"message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。"
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
"message": "비공개 Watch Party를 만들고 YouTube, Netflix, Jellyfin, Emby 및 거의 모든 HTML5 사이트에서 친구들과 영상을 동기화하세요."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
"message": "Maak privéwatchparty's en kijk synchroon met vrienden op YouTube, Netflix, Jellyfin, Emby en vrijwel elke HTML5-website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
"message": "Twórz prywatne wspólne seanse i synchronizuj filmy ze znajomymi na YouTube, Netflix, Jellyfin, Emby i niemal każdej stronie HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5."
"message": "Crie watch parties privadas e sincronize vídeos com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5."
"message": "Crie sessões privadas e veja vídeos sincronizados com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями."
"message": "Создавайте закрытые Watch Party и смотрите синхронно с друзьями на YouTube, Netflix, Jellyfin, Emby и почти любом сайте HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
"message": "Özel izleme partileri kurun; YouTube, Netflix, Jellyfin, Emby ve çoğu HTML5 sitesinde arkadaşlarınızla senkron izleyin."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Синхронізуйте відтворення відео на YouTube, Netflix, Emby, Jellyfin і будь-якому сайті HTML5 у режимі реального часу з друзями."
"message": "Створюйте закриті Watch Party й дивіться синхронно з друзями на YouTube, Netflix, Jellyfin, Emby та майже будь-якому сайті HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin任何HTML5网站上的视频播放。"
"message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。"
}
}
+676 -88
View File
@@ -7,6 +7,7 @@ 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 './page-api-seek-overrides.js';
// --- Uninstall URL Initialization ---
@@ -64,6 +65,8 @@ let peerId = null; // initialized via getPeerId()
let currentRoom = null;
let currentTabId = null;
let currentTabTitle = null; // New: for Smart Matching
let targetActivationGeneration = 0;
let activeTargetActivation = null;
let logs = [];
let history = []; // New: for Action History
let storageInitialized = false;
@@ -191,8 +194,12 @@ function ensureState() {
], (data) => {
clearTimeout(storageTimeout);
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId);
if (data.currentTabTitle !== undefined) {
currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string'
? data.currentTabTitle
: null;
}
// Merge data from storage with any early-arriving state
// New entries (added during boot) must stay at the top (index 0)
if (data.logs) logs = [...logs, ...data.logs].slice(0, 200);
@@ -545,7 +552,28 @@ function markRoomPotentiallyIdle() {
}
}
function clearTargetTabForIdle() {
function invalidateTargetActivations() {
targetActivationGeneration++;
activeTargetActivation = null;
return targetActivationGeneration;
}
function isCurrentTargetIdentity(tabId, generation) {
return normalizeTabId(currentTabId) === normalizeTabId(tabId)
&& targetActivationGeneration === generation;
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
return false;
}
if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) {
return false;
}
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
clearPendingTarget().catch(() => {});
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
currentTabId = null;
currentTabTitle = null;
@@ -553,8 +581,14 @@ function clearTargetTabForIdle() {
if (currentRoom) {
roomIdleSince = Date.now();
}
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
chrome.storage.session.set({
currentTabId,
currentTabTitle,
roomIdleSince,
lastContentHeartbeatAt
}).catch(() => {});
updateBadgeStatus();
return true;
}
async function leaveRoomAfterIdleGrace(reason) {
@@ -563,6 +597,7 @@ async function leaveRoomAfterIdleGrace(reason) {
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
completeForceSyncBeforeTargetChange(null);
emit(EVENTS.LEAVE_ROOM, { peerId });
forceDisconnect();
currentRoom = null;
@@ -575,11 +610,13 @@ async function leaveRoomAfterIdleGrace(reason) {
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
roomIdleSince = null;
lastContentHeartbeatAt = null;
clearEpisodeLobbyState();
await clearPendingTarget();
await chrome.storage.session.set({
currentRoom: null,
currentTabId: null,
@@ -1536,6 +1573,16 @@ function executeForceSync() {
addLog('Force Sync Executed', 'success');
}
function completeForceSyncBeforeTargetChange(nextTabId) {
if (!isForceSyncInitiator) return;
const selectedTabId = normalizeTabId(currentTabId);
const normalizedNextTabId = normalizeTabId(nextTabId);
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
addLog('Finishing Force Sync before target change', 'info');
executeForceSync();
}
// --- Episode Auto-Sync Lobby Functions ---
function persistEpisodeLobby() {
if (storageInitialized) chrome.storage.session.set({ episodeLobby });
@@ -1683,13 +1730,14 @@ function updateLastAction(action, senderId, timestamp = Date.now()) {
async function routeToContent(action, payload) {
if (!currentTabId) return;
const tabId = parseInt(currentTabId);
if (isNaN(tabId)) return;
const tabId = normalizeTabId(currentTabId);
if (tabId === null) return;
const targetGeneration = targetActivationGeneration;
const actionTimestamp = payload?.actionTimestamp || Date.now();
const commandSenderId = payload?.senderId || null;
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0);
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0, targetGeneration);
}
function getTabVideoState(tabId) {
@@ -1704,12 +1752,21 @@ function getTabVideoState(tabId) {
});
}
async function getReadyTabVideoState(tabId) {
async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) {
let state = await getTabVideoState(tabId);
if (!state || state.error) {
await injectContentScript(tabId);
const activation = await reactivateCurrentTarget(tabId, { expectedGeneration });
if (activation?.status !== 'ok') {
return { error: 'Target tab changed before content script recovery completed' };
}
await new Promise(resolve => setTimeout(resolve, 250));
if (!isCurrentTargetIdentity(tabId, activation.generation)) {
return { error: 'Target tab changed before video state could be read' };
}
state = await getTabVideoState(tabId);
if (!isCurrentTargetIdentity(tabId, activation.generation)) {
return { error: 'Target tab changed while video state was being read' };
}
}
return state;
}
@@ -1830,51 +1887,533 @@ function setPageApiSeekEnabled(enabled) {
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true;
}
async function injectContentScript(tabId) {
function createHostAccessRequiredError(access, requestAdded, cause) {
const error = new Error(`Host access required for ${access.host || 'this website'}`);
error.code = HOST_ACCESS_REQUIRED_STATUS;
error.tabId = access.tab?.id || null;
error.host = access.host || null;
error.originPattern = access.originPattern || null;
error.requestAdded = requestAdded === true;
error.cause = cause;
return error;
}
function injectionFailureResponse(error) {
if (error?.code === HOST_ACCESS_REQUIRED_STATUS) {
return {
status: HOST_ACCESS_REQUIRED_STATUS,
tabId: error.tabId,
host: error.host,
originPattern: error.originPattern,
requestAdded: error.requestAdded === true
};
}
return { status: 'error', message: error?.message || 'Script injection failed' };
}
async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) throw new Error('Invalid tab ID');
tabId = normalizedTabId;
let needsPageApiSeek = false;
let pageApiSeekReady = false;
let access = null;
try {
const tab = await chrome.tabs.get(tabId);
const url = tab?.url || '';
access = await inspectTabHostAccess(chrome, tabId);
const url = access.url || '';
needsPageApiSeek = shouldUsePageApiSeek(url);
} catch (_e) {
// Fall through to the generic content script injection.
}
if (needsPageApiSeek) {
try {
await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
files: ['page-api-seek-overrides.js']
});
await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
func: installPageApiSeekBridge
});
pageApiSeekReady = true;
} catch (err) {
addLog(`Page API seek bridge injection failed: ${err.message}`, 'warn');
try {
if (needsPageApiSeek) {
try {
await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
files: ['page-api-seek-overrides.js']
});
await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
func: installPageApiSeekBridge
});
pageApiSeekReady = true;
} catch (err) {
addLog(`Page API seek bridge injection failed: ${err.message}`, 'warn');
}
}
}
await chrome.scripting.executeScript({
target: { tabId },
files: ['page-api-seek-overrides.js']
});
await chrome.scripting.executeScript({
target: { tabId },
func: setPageApiSeekEnabled,
args: [pageApiSeekReady]
});
return chrome.scripting.executeScript({
target: { tabId },
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
await chrome.scripting.executeScript({
target: { tabId },
files: ['page-api-seek-overrides.js']
});
await chrome.scripting.executeScript({
target: { tabId },
func: setPageApiSeekEnabled,
args: [pageApiSeekReady]
});
return await chrome.scripting.executeScript({
target: { tabId },
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
});
} catch (error) {
// A temporary activeTab grant is intentionally allowed to win: even if
// permissions.contains() reports false, a successful injection above is
// valid. Only convert an actual injection failure into a host-access UX.
try {
// The tab may have crossed origins between the initial permission
// check and executeScript(). Always report/request the current URL.
access = await inspectTabHostAccess(chrome, tabId);
} catch (_inspectionError) {
access = null;
}
const accessIsMissing = access?.granted === false
|| (access?.granted === null && isHostAccessError(error));
if (access?.originPattern && accessIsMissing) {
const requestAdded = requestHostAccess
? await addTabHostAccessRequest(chrome, tabId, access.originPattern)
: false;
throw createHostAccessRequiredError(access, requestAdded, error);
}
throw error;
}
}
let pendingTargetMutation = Promise.resolve();
const PENDING_TARGET_KEYS = [
'pendingTargetTabId',
'pendingTargetTabTitle',
'pendingTargetHost',
'pendingTargetOriginPattern',
'pendingTargetRequestId'
];
function createPendingTargetRequestId() {
return globalThis.crypto?.randomUUID?.()
|| `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function emptyPendingTargetState() {
return {
pendingTargetTabId: null,
pendingTargetTabTitle: null,
pendingTargetHost: null,
pendingTargetOriginPattern: null,
pendingTargetRequestId: null
};
}
function mutatePendingTarget(operation) {
const result = pendingTargetMutation.catch(() => {}).then(operation);
pendingTargetMutation = result.catch(() => {});
return result;
}
async function readPendingTarget() {
return mutatePendingTarget(async () => {
const stored = await chrome.storage.session.get(PENDING_TARGET_KEYS);
const tabId = normalizeTabId(stored.pendingTargetTabId);
const originPattern = typeof stored.pendingTargetOriginPattern === 'string'
&& stored.pendingTargetOriginPattern.length > 0
? stored.pendingTargetOriginPattern
: null;
if (tabId === null || originPattern === null) {
if (Object.values(stored).some(value => value !== null && value !== undefined)) {
if (tabId !== null) {
await removeTabHostAccessRequest(chrome, tabId, originPattern);
}
await chrome.storage.session.set(emptyPendingTargetState());
}
return null;
}
const requestId = typeof stored.pendingTargetRequestId === 'string'
&& stored.pendingTargetRequestId.length > 0
? stored.pendingTargetRequestId
: createPendingTargetRequestId();
if (stored.pendingTargetRequestId !== requestId) {
await chrome.storage.session.set({ pendingTargetRequestId: requestId });
}
return {
tabId,
tabTitle: typeof stored.pendingTargetTabTitle === 'string'
? stored.pendingTargetTabTitle
: null,
host: typeof stored.pendingTargetHost === 'string'
? stored.pendingTargetHost
: null,
originPattern,
requestId
};
});
}
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
async function rememberPendingTarget(tabId, tabTitle, error, expectedGeneration) {
return mutatePendingTarget(async () => {
if (targetActivationGeneration !== expectedGeneration) return null;
const previous = await chrome.storage.session.get(PENDING_TARGET_KEYS);
const previousTabId = normalizeTabId(previous.pendingTargetTabId);
const nextOriginPattern = error?.originPattern || null;
if (previousTabId !== null && (
previousTabId !== tabId
|| previous.pendingTargetOriginPattern !== nextOriginPattern
)) {
await removeTabHostAccessRequest(
chrome,
previousTabId,
previous.pendingTargetOriginPattern || null
);
}
if (targetActivationGeneration !== expectedGeneration) return null;
const requestId = previousTabId === tabId
&& previous.pendingTargetOriginPattern === nextOriginPattern
&& typeof previous.pendingTargetRequestId === 'string'
&& previous.pendingTargetRequestId.length > 0
? previous.pendingTargetRequestId
: createPendingTargetRequestId();
await chrome.storage.session.set({
pendingTargetTabId: tabId,
pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null,
pendingTargetHost: error?.host || null,
pendingTargetOriginPattern: nextOriginPattern,
pendingTargetRequestId: requestId
});
if (targetActivationGeneration !== expectedGeneration) {
const current = await chrome.storage.session.get(PENDING_TARGET_KEYS);
if (current.pendingTargetRequestId === requestId) {
await removeTabHostAccessRequest(chrome, tabId, nextOriginPattern);
await chrome.storage.session.set(emptyPendingTargetState());
}
return null;
}
return {
tabId,
tabTitle: typeof tabTitle === 'string' ? tabTitle : null,
host: error?.host || null,
originPattern: nextOriginPattern,
requestId
};
});
}
async function clearPendingTarget({ expectedRequestId = null, expectedTabId = null } = {}) {
return mutatePendingTarget(async () => {
const pending = await chrome.storage.session.get(PENDING_TARGET_KEYS);
const pendingTabId = normalizeTabId(pending.pendingTargetTabId);
if (expectedRequestId !== null && pending.pendingTargetRequestId !== expectedRequestId) {
return false;
}
if (expectedTabId !== null && pendingTabId !== normalizeTabId(expectedTabId)) {
return false;
}
if (pendingTabId !== null) {
await removeTabHostAccessRequest(
chrome,
pendingTabId,
pending.pendingTargetOriginPattern || null
);
}
await chrome.storage.session.set(emptyPendingTargetState());
return true;
});
}
async function activateTargetTab(tabId, tabTitle, {
requestHostAccess = true,
expectedGeneration = null,
expectedCurrentTabId = null
} = {}) {
const selectedTabId = normalizeTabId(tabId);
if (selectedTabId === null) {
return { status: 'invalid_tab' };
}
if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) {
return { status: 'superseded' };
}
if (expectedCurrentTabId !== null
&& normalizeTabId(currentTabId) !== normalizeTabId(expectedCurrentTabId)) {
return { status: 'superseded' };
}
completeForceSyncBeforeTargetChange(selectedTabId);
const activationGeneration = ++targetActivationGeneration;
activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId };
const previousTabId = normalizeTabId(currentTabId);
try {
try {
await injectContentScript(selectedTabId, { requestHostAccess });
} catch (error) {
if (activationGeneration !== targetActivationGeneration) {
if (error?.code === HOST_ACCESS_REQUIRED_STATUS
&& error.requestAdded === true
&& activeTargetActivation?.tabId !== selectedTabId) {
await removeTabHostAccessRequest(
chrome,
selectedTabId,
error.originPattern || null
);
}
return { status: 'superseded' };
}
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
}
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince,
lastContentHeartbeatAt: null
});
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
updateBadgeStatus();
if (error?.code === HOST_ACCESS_REQUIRED_STATUS) {
const pending = await rememberPendingTarget(
selectedTabId,
tabTitle,
error,
activationGeneration
);
if (!pending || activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
chrome.runtime.sendMessage({
type: 'TARGET_TAB_ACCESS_REQUIRED',
requestId: pending.requestId,
...injectionFailureResponse(error)
}).catch(() => {});
} else {
await clearPendingTarget();
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
}
throw error;
}
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
await applyAudioSettingsToTab(selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
await removeTabHostAccessRequest(chrome, selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
await clearPendingTarget();
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
currentTabId = selectedTabId;
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId && previousTabId !== selectedTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
}
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
roomIdleSince,
lastContentHeartbeatAt
});
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
updateBadgeStatus();
return { status: 'ok', tabId: selectedTabId, generation: activationGeneration };
} finally {
if (activeTargetActivation?.generation === activationGeneration) {
activeTargetActivation = null;
}
}
}
async function reactivateCurrentTarget(tabId, { expectedGeneration = targetActivationGeneration } = {}) {
const selectedTabId = normalizeTabId(tabId);
if (selectedTabId === null || !isCurrentTargetIdentity(selectedTabId, expectedGeneration)) {
return { status: 'superseded' };
}
if (activeTargetActivation && activeTargetActivation.tabId !== selectedTabId) {
return { status: 'superseded' };
}
return activateTargetTab(selectedTabId, currentTabTitle, {
expectedGeneration,
expectedCurrentTabId: selectedTabId
});
}
async function retryPendingTarget({ expectedRequestId = null, requireGrantedAccess = false } = {}) {
let pending = await readPendingTarget();
if (!pending || (expectedRequestId !== null && pending.requestId !== expectedRequestId)) {
return null;
}
if (activeTargetActivation && activeTargetActivation.tabId !== pending.tabId) {
return { status: 'superseded' };
}
if (requireGrantedAccess) {
let access;
try {
access = await inspectTabHostAccess(chrome, pending.tabId);
} catch {
await clearPendingTarget({
expectedRequestId: pending.requestId,
expectedTabId: pending.tabId
});
return { status: 'invalid_tab' };
}
if (access.granted !== true || access.originPattern !== pending.originPattern) {
return { status: 'permission_not_granted' };
}
pending = await readPendingTarget();
if (!pending || pending.requestId !== expectedRequestId) {
return { status: 'superseded' };
}
}
try {
const expectedGeneration = targetActivationGeneration;
const response = await activateTargetTab(pending.tabId, pending.tabTitle, {
requestHostAccess: false,
expectedGeneration
});
if (response.status === 'ok') {
addLog(`Website access granted; selected tab ${pending.tabId}`, 'success');
chrome.runtime.sendMessage({
type: 'TARGET_TAB_READY',
tabId: pending.tabId,
requestId: pending.requestId
}).catch(() => {});
}
return response;
} catch (error) {
if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) {
await clearPendingTarget({
expectedRequestId: pending.requestId,
expectedTabId: pending.tabId
});
addLog(`Pending tab activation failed: ${error.message}`, 'warn');
}
return injectionFailureResponse(error);
}
}
if (chrome.permissions?.onAdded?.addListener) {
chrome.permissions.onAdded.addListener((addedPermissions) => {
ensureState().then(async () => {
const pending = await readPendingTarget();
if (!pending) return;
const addedOrigins = Array.isArray(addedPermissions?.origins)
? addedPermissions.origins
: [];
if (!addedOrigins.includes(pending.originPattern) && !addedOrigins.includes('<all_urls>')) {
return;
}
await retryPendingTarget({
expectedRequestId: pending.requestId,
requireGrantedAccess: true
});
}).catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn'));
});
}
if (chrome.tabs?.onRemoved?.addListener) {
chrome.tabs.onRemoved.addListener((removedTabId) => {
ensureState().then(async () => {
const tabId = normalizeTabId(removedTabId);
if (tabId === null) return;
const pending = await readPendingTarget();
const isCurrent = normalizeTabId(currentTabId) === tabId;
const isPending = pending?.tabId === tabId;
const isActivating = activeTargetActivation?.tabId === tabId;
if (!isCurrent && !isPending && !isActivating) return;
const hasReplacementActivation = activeTargetActivation
&& activeTargetActivation.tabId !== tabId;
if (isCurrent) completeForceSyncBeforeTargetChange(null);
if (isActivating || (isCurrent && !hasReplacementActivation)) {
invalidateTargetActivations();
}
if (isCurrent) {
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
}
if (isPending) {
await clearPendingTarget({
expectedRequestId: pending.requestId,
expectedTabId: tabId
});
}
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
roomIdleSince,
lastContentHeartbeatAt
});
updateBadgeStatus();
chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {});
if (isCurrent) {
addLog('Target tab closed.', 'warn');
if (currentRoom) {
const roomAtClose = currentRoom;
getSettings().then(settings => {
if (currentRoom !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId,
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
const me = currentRoom?.peers?.find(p => (p.peerId || p) === peerId);
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (storageInitialized) {
chrome.storage.session.set({ currentRoom });
}
chrome.runtime.sendMessage({
type: 'PEER_UPDATE',
peers: currentRoom.peers
}).catch(() => {});
}
}).catch(() => {});
}
}
}).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn'));
});
}
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) {
if (!isCurrentTargetIdentity(tabId, targetGeneration)) return;
chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
action,
@@ -1882,20 +2421,30 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
actionTimestamp,
commandSenderId
}).catch(err => {
if (!isCurrentTargetIdentity(tabId, targetGeneration)) return;
if (retries >= 3) {
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
clearTargetTabForIdle();
clearTargetTabForIdle(tabId, targetGeneration);
return;
}
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
injectContentScript(tabId).then(() => {
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
reactivateCurrentTarget(tabId, { expectedGeneration: targetGeneration }).then(response => {
if (response?.status !== 'ok') return;
setTimeout(() => _routeToContentInternal(
tabId,
action,
payload,
actionTimestamp,
commandSenderId,
retries + 1,
response.generation
), 500);
}).catch(_err => {
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
});
} else {
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
clearTargetTabForIdle();
clearTargetTabForIdle(tabId, targetGeneration);
}
});
}
@@ -2058,6 +2607,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
connect();
sendResponse({ status: 'ok' });
} else if (message.type === 'GET_STATUS') {
if (message.retryPendingTarget === true) {
await retryPendingTarget();
}
const pendingTarget = await readPendingTarget();
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
@@ -2069,6 +2622,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peers: currentRoom ? currentRoom.peers : [],
lastActionState,
targetTabId: currentTabId,
pendingTargetTabId: pendingTarget?.tabId ?? null,
pendingTargetHost: pendingTarget?.host ?? null,
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
pendingTargetRequestId: pendingTarget?.requestId ?? null,
episodeLobby: episodeLobby,
reconnectAttempts,
reconnectSlowMode: reconnectFailed,
@@ -2212,6 +2769,10 @@ 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)) {
sendResponse({ status: 'ignored_unselected_tab', target: null });
return;
}
// content.js resync: hand back the host's extrapolated current position.
sendResponse({ target: getHostSyncTarget() });
} else if (message.type === 'GET_HCM_STRINGS') {
@@ -2235,7 +2796,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 && currentTabId && currentTabId !== sender.tab.id) {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -2247,6 +2808,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
webJoinCoordinator.invalidate();
completeForceSyncBeforeTargetChange(null);
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
@@ -2263,6 +2825,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
roomIdleSince = null;
@@ -2277,6 +2840,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// Cancel any active episode lobby
clearEpisodeLobbyState();
await clearPendingTarget();
chrome.storage.session.set({
currentRoom: null,
@@ -2432,6 +2996,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'error', message: err.message });
});
} else if (message.type === 'CONTENT_EVENT') {
if (!sender?.tab && message.expectedTabId !== undefined) {
const expectedTabId = normalizeTabId(message.expectedTabId);
if (expectedTabId === null || normalizeTabId(currentTabId) !== expectedTabId) {
sendResponse({ status: 'stale_target' });
return;
}
}
const processEvent = async () => {
// Host Control Mode (sender-side): a non-controller in host-only mode must
// not drive the room. Don't broadcast; hand the action back to content.js so
@@ -2575,6 +3146,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
});
}
} else if (message.type === 'FORCE_SYNC_ACK') {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
if (isForceSyncInitiator) {
forceSyncAcks.add(peerId);
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
@@ -2599,6 +3174,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
const commandSenderId = message.commandSenderId;
// Only ACK if the command sender is still a known peer in our room.
// If we've already seen their PEER_STATUS 'left', skip the ACK — it would
@@ -2662,45 +3241,67 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
});
} else if (message.type === 'INJECT_CONTENT_SCRIPT') {
const tabId = Number(message.tabId);
if (!Number.isInteger(tabId)) {
const tabId = normalizeTabId(message.tabId);
if (tabId === null) {
sendResponse({ status: 'invalid_tab' });
return true;
}
injectContentScript(tabId).then(() => {
sendResponse({ status: 'ok' });
const expectedCurrentTabId = normalizeTabId(message.expectedCurrentTabId);
if (expectedCurrentTabId === null
|| tabId !== expectedCurrentTabId
|| normalizeTabId(currentTabId) !== expectedCurrentTabId) {
sendResponse({ status: 'stale_target' });
return true;
}
reactivateCurrentTarget(tabId, {
expectedGeneration: targetActivationGeneration
}).then(response => {
sendResponse(response);
}).catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
sendResponse({ status: 'error', message: err.message });
sendResponse(injectionFailureResponse(err));
});
return true;
} else if (message.type === 'SET_TARGET_TAB') {
const previousTabId = currentTabId;
currentTabId = message.tabId;
currentTabTitle = message.tabTitle;
lastContentHeartbeatAt = null;
if (currentRoom) {
roomIdleSince = Date.now();
if (message.tabId === null || message.tabId === undefined || message.tabId === '') {
const previousTabId = currentTabId;
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(Number(previousTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
}
await clearPendingTarget();
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince,
lastContentHeartbeatAt: null
});
updateBadgeStatus();
sendResponse({ status: 'ok', tabId: null });
return;
}
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt });
updateBadgeStatus();
if (previousTabId && previousTabId !== currentTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(Number(previousTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
try {
const response = await activateTargetTab(message.tabId, message.tabTitle);
if (response?.status === 'ok') {
chrome.runtime.sendMessage({
type: 'TARGET_TAB_READY',
tabId: response.tabId
}).catch(() => {});
}
sendResponse(response);
} catch (error) {
addLog(`Failed to select tab: ${error.message}`, 'warn');
sendResponse(injectionFailureResponse(error));
}
if (currentTabId) {
const selectedTabId = currentTabId;
injectContentScript(selectedTabId)
.then(() => applyAudioSettingsToTab(selectedTabId))
.catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
});
}
sendResponse({ status: 'ok' });
} else if (message.type === 'LOG') {
addLog(`[Content] ${message.message}`, message.level || 'info');
sendResponse({ status: 'ok' });
@@ -2883,21 +3484,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
initTabManager({
getCurrentTabId: () => currentTabId,
setCurrentTabId: (val) => { currentTabId = val; },
setCurrentTabTitle: (val) => { currentTabTitle = val; },
setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; },
setRoomIdleSince: (val) => { roomIdleSince = val; },
getCurrentRoom: () => currentRoom,
getPeerId: () => peerId,
getStorageInitialized: () => storageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
injectContentScript,
ensureState,
EVENTS
reactivateCurrentTarget,
ensureState
});
// Initial Connect — only if user has an active room configuration
+2 -1
View File
@@ -1,8 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(new URL(import.meta.url).pathname);
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
+194
View File
@@ -0,0 +1,194 @@
export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required';
export function normalizeTabId(value) {
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value > 0 ? value : null;
}
if (typeof value !== 'string') return null;
const normalized = value.trim();
if (!/^[1-9]\d*$/.test(normalized)) return null;
const tabId = Number(normalized);
return Number.isSafeInteger(tabId) ? tabId : null;
}
function browserSupportsPortMatchPatterns(chromeApi) {
// runtime.getBrowserInfo is a Firefox-only WebExtension API. Firefox does
// not accept ports in match patterns, while Chromium does.
return typeof chromeApi?.runtime?.getBrowserInfo !== 'function';
}
export function describeTabUrl(rawUrl, { includePort = true } = {}) {
if (typeof rawUrl !== 'string' || !rawUrl) return null;
try {
const url = new URL(rawUrl);
if (url.protocol === 'http:' || url.protocol === 'https:') {
const permissionHost = includePort ? url.host : url.hostname;
return {
url: rawUrl,
host: url.host,
originPattern: `${url.protocol}//${permissionHost}/*`
};
}
if (url.protocol === 'file:') {
return {
url: rawUrl,
host: 'local file',
originPattern: 'file:///*'
};
}
} catch (_e) {
// Invalid and browser-internal URLs cannot receive host access.
}
return null;
}
export async function inspectTabHostAccess(chromeApi, tabId) {
const tab = await chromeApi.tabs.get(tabId);
// executeScript targets the committed document. pendingUrl may already point
// at another origin while tab.url is still the document being injected into.
const descriptor = describeTabUrl(tab?.url || tab?.pendingUrl || '', {
includePort: browserSupportsPortMatchPatterns(chromeApi)
});
if (!descriptor) {
return {
tab,
url: tab?.url || '',
host: null,
originPattern: null,
granted: null
};
}
if (typeof chromeApi.permissions?.contains !== 'function') {
return { tab, ...descriptor, granted: null };
}
try {
const granted = await callBooleanPermissionMethod(
chromeApi,
'contains',
{ origins: [descriptor.originPattern] },
{ timeoutMs: 1000 }
);
return {
tab,
...descriptor,
granted: granted === null ? null : granted === true
};
} catch (_e) {
// Permission inspection is advisory. The actual script injection below
// remains the source of truth, including temporary activeTab access.
return { tab, ...descriptor, granted: null };
}
}
function callBooleanPermissionMethod(chromeApi, methodName, request, { timeoutMs = null } = {}) {
const method = chromeApi.permissions?.[methodName];
if (typeof method !== 'function') return Promise.resolve(null);
return new Promise((resolve, reject) => {
let settled = false;
let timeout = null;
const clearSettlementTimeout = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
};
const finish = (value) => {
if (settled) return;
settled = true;
clearSettlementTimeout();
resolve(value === true ? true : value === false ? false : null);
};
const fail = (error) => {
if (settled) return;
settled = true;
clearSettlementTimeout();
reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed')));
};
const callback = (value) => {
const lastError = chromeApi.runtime?.lastError;
if (lastError) {
fail(new Error(lastError.message || String(lastError)));
return;
}
finish(value);
};
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
timeout = setTimeout(() => finish(null), timeoutMs);
}
try {
const result = method.call(chromeApi.permissions, request, callback);
if (result && typeof result.then === 'function') {
result.then(finish, fail);
} else if (typeof result === 'boolean') {
finish(result);
}
// Callback-based Chromium/Firefox APIs return undefined and settle
// through callback. Promise-based implementations settle above.
} catch (error) {
fail(error);
}
});
}
export function requestOriginPermission(chromeApi, originPattern) {
if (typeof originPattern !== 'string' || !originPattern) {
return Promise.resolve(null);
}
return callBooleanPermissionMethod(
chromeApi,
'request',
{ origins: [originPattern] },
{ timeoutMs: 60000 }
).catch(() => false);
}
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);
}
function hostAccessRequestDetails(tabId, originPattern) {
const request = { tabId };
if (typeof originPattern === 'string' && originPattern) {
request.pattern = originPattern;
}
return request;
}
export async function addTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') {
return false;
}
try {
await chromeApi.permissions.addHostAccessRequest(
hostAccessRequestDetails(tabId, originPattern)
);
return true;
} catch (_e) {
return false;
}
}
export async function removeTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') {
return false;
}
try {
await chromeApi.permissions.removeHostAccessRequest(
hostAccessRequestDetails(tabId, originPattern)
);
return true;
} catch (_e) {
return false;
}
}
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identität noch nicht geladen. Warte kurz und versuche es erneut.",
"ERR_NO_PEERS_TIME": "Keine anderen Teilnehmer mit bekannter Zeit. Wechsle zu 'Zu mir springen'.",
"ERR_NO_VIDEO_TAB": "Verbindung zum Video-Tab fehlgeschlagen.",
"SITE_ACCESS_REQUIRED": "Dein Browser blockiert KoalaSync auf {host}. Klicke unten auf „Zugriff erlauben“. Falls kein Dialog erscheint, erlaube KoalaSync in den Erweiterungs- oder Websiteberechtigungen für diese Seite.",
"BTN_RETRY_ACCESS": "Zugriff erlauben",
"ERR_SELECT_VIDEO": "Bitte wähle zuerst ein Video aus!",
"TOAST_INVITE_COPIED": "Einladungslink kopiert!",
"TOAST_COPY_FAILED": "Kopieren in Zwischenablage fehlgeschlagen",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identity not yet loaded. Wait a moment and try again.",
"ERR_NO_PEERS_TIME": "No other peers with a known time. Switch to 'Jump to Me'.",
"ERR_NO_VIDEO_TAB": "Could not connect to video tab.",
"SITE_ACCESS_REQUIRED": "Your browser is blocking KoalaSync on {host}. Click \"Allow access\" below. If no dialog appears, allow KoalaSync on this site in your extension or site permissions.",
"BTN_RETRY_ACCESS": "Allow access",
"ERR_SELECT_VIDEO": "Please select a video first!",
"TOAST_INVITE_COPIED": "Invite link copied!",
"TOAST_COPY_FAILED": "Failed to copy to clipboard",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidad no cargada aún. Espera un momento e inténtalo de nuevo.",
"ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mi posición'.",
"ERR_NO_VIDEO_TAB": "No se pudo conectar a la pestaña de video.",
"SITE_ACCESS_REQUIRED": "Tu navegador está bloqueando KoalaSync en {host}. Haz clic en «Permitir acceso». Si no aparece ningún diálogo, permite KoalaSync en este sitio desde los permisos de extensiones o del sitio.",
"BTN_RETRY_ACCESS": "Permitir acceso",
"ERR_SELECT_VIDEO": "¡Selecciona un video primero!",
"TOAST_INVITE_COPIED": "¡Enlace de invitación copiado!",
"TOAST_COPY_FAILED": "Error al copiar al portapapeles",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identifiant non encore chargé. Veuillez patienter et réessayer.",
"ERR_NO_PEERS_TIME": "Aucun autre membre avec une position connue. Basculez sur 'Les amener à moi'.",
"ERR_NO_VIDEO_TAB": "Impossible de se connecter à l'onglet vidéo.",
"SITE_ACCESS_REQUIRED": "Votre navigateur bloque KoalaSync sur {host}. Cliquez sur « Autoriser laccès ». Si aucune boîte de dialogue napparaît, autorisez KoalaSync sur ce site dans les permissions des extensions ou du site.",
"BTN_RETRY_ACCESS": "Autoriser laccès",
"ERR_SELECT_VIDEO": "Veuillez d'abord sélectionner une vidéo !",
"TOAST_INVITE_COPIED": "Lien d'invitation copié !",
"TOAST_COPY_FAILED": "Échec de la copie dans le presse-papiers",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento.",
"ERR_NO_PEERS_TIME": "Nessun altro partecipante con posizione nota. Passa a 'Portali alla mia posizione'.",
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
"SITE_ACCESS_REQUIRED": "Il browser sta bloccando KoalaSync su {host}. Fai clic su «Consenti accesso». Se non appare alcuna finestra, consenti KoalaSync su questo sito nelle autorizzazioni dellestensione o del sito.",
"BTN_RETRY_ACCESS": "Consenti accesso",
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
"TOAST_INVITE_COPIED": "Link di invito copiato!",
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "IDがまだロードされていません。しばらく待ってから再試行してください。",
"ERR_NO_PEERS_TIME": "既知の時間を持つ他のメンバーがいません。「自分に合わせる」に切り替えてください。",
"ERR_NO_VIDEO_TAB": "ビデオタブに接続できませんでした。",
"SITE_ACCESS_REQUIRED": "ブラウザーが {host} で KoalaSync をブロックしています。下の「アクセスを許可」をクリックしてください。ダイアログが表示されない場合は、拡張機能またはサイトの権限でこのサイトの KoalaSync を許可してください。",
"BTN_RETRY_ACCESS": "アクセスを許可",
"ERR_SELECT_VIDEO": "最初にビデオを選択してください!",
"TOAST_INVITE_COPIED": "招待リンクをコピーしました!",
"TOAST_COPY_FAILED": "クリップボードへのコピーに失敗しました",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.",
"ERR_NO_PEERS_TIME": "시간을 알고 있는 다른 피어가 없습니다. '나에게 이동'으로 전환하세요.",
"ERR_NO_VIDEO_TAB": "비디오 탭에 연결할 수 없습니다.",
"SITE_ACCESS_REQUIRED": "브라우저가 {host}에서 KoalaSync를 차단하고 있습니다. 아래의 ‘액세스 허용’을 클릭하세요. 대화상자가 표시되지 않으면 확장 프로그램 또는 사이트 권한에서 이 사이트의 KoalaSync를 허용하세요.",
"BTN_RETRY_ACCESS": "액세스 허용",
"ERR_SELECT_VIDEO": "먼저 비디오를 선택하세요!",
"TOAST_INVITE_COPIED": "초대 링크가 복사되었습니다!",
"TOAST_COPY_FAILED": "클립보드 복사에 실패했습니다",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.",
"ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.",
"ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.",
"SITE_ACCESS_REQUIRED": "Je browser blokkeert KoalaSync op {host}. Klik hieronder op Toegang toestaan. Als er geen dialoog verschijnt, sta KoalaSync voor deze site toe via de extensie- of siterechten.",
"BTN_RETRY_ACCESS": "Toegang toestaan",
"ERR_SELECT_VIDEO": "Selecteer eerst een video!",
"TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!",
"TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.",
"ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.",
"ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.",
"SITE_ACCESS_REQUIRED": "Przeglądarka blokuje KoalaSync na {host}. Kliknij poniżej „Zezwól na dostęp”. Jeśli nie pojawi się okno, zezwól KoalaSync na tej stronie w uprawnieniach rozszerzenia lub witryny.",
"BTN_RETRY_ACCESS": "Zezwól na dostęp",
"ERR_SELECT_VIDEO": "Najpierw wybierz wideo!",
"TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!",
"TOAST_COPY_FAILED": "Nie udało się skopiować do schowka",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento.",
"ERR_NO_PEERS_TIME": "Nenhum participante com posição conhecida. Mude para 'Trazer para a minha posição'.",
"ERR_NO_VIDEO_TAB": "Não foi possível conectar à aba do vídeo.",
"SITE_ACCESS_REQUIRED": "Seu navegador está bloqueando o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se nenhuma caixa de diálogo aparecer, permita o KoalaSync neste site nas permissões da extensão ou do site.",
"BTN_RETRY_ACCESS": "Permitir acesso",
"ERR_SELECT_VIDEO": "Selecione um vídeo primeiro!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento.",
"ERR_NO_PEERS_TIME": "Nenhum participante com hora conhecida. Mude para 'Trazer para a minha posição'.",
"ERR_NO_VIDEO_TAB": "Não foi possível aceder ao separador do vídeo.",
"SITE_ACCESS_REQUIRED": "O seu navegador está a bloquear o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se não aparecer uma caixa de diálogo, permita o KoalaSync neste site nas permissões da extensão ou do site.",
"BTN_RETRY_ACCESS": "Permitir acesso",
"ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Идентификатор еще не загружен. Подождите немного и повторите попытку.",
"ERR_NO_PEERS_TIME": "Нет других участников с известной позицией плеера. Выберите 'Синхронизировать по мне'.",
"ERR_NO_VIDEO_TAB": "Не удалось подключиться к вкладке с видео.",
"SITE_ACCESS_REQUIRED": "Браузер блокирует KoalaSync на {host}. Нажмите «Разрешить доступ» ниже. Если диалог не появится, разрешите KoalaSync для этого сайта в настройках расширения или разрешениях сайта.",
"BTN_RETRY_ACCESS": "Разрешить доступ",
"ERR_SELECT_VIDEO": "Пожалуйста, сначала выберите вкладку с видео!",
"TOAST_INVITE_COPIED": "Ссылка скопирована в буфер обмена!",
"TOAST_COPY_FAILED": "Не удалось скопировать ссылку в буфер обмена",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Kimlik henüz yüklenmedi. Biraz bekleyin ve tekrar deneyin.",
"ERR_NO_PEERS_TIME": "Bilinen bir zamanı olan başka bağlantı yok. 'Bana Atla' seçeneğine geçin.",
"ERR_NO_VIDEO_TAB": "Video sekmesine bağlanılamadı.",
"SITE_ACCESS_REQUIRED": "Tarayıcınız {host} üzerinde KoalaSync'i engelliyor. Aşağıdaki “Erişime izin ver” düğmesine tıklayın. İletişim kutusu görünmezse uzantı veya site izinlerinden bu site için KoalaSync'e izin verin.",
"BTN_RETRY_ACCESS": "Erişime izin ver",
"ERR_SELECT_VIDEO": "Lütfen önce bir video seçin!",
"TOAST_INVITE_COPIED": "Davet bağlantısı kopyalandı!",
"TOAST_COPY_FAILED": "Panoya kopyalanamadı",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Посвідчення особи ще не завантажено. Зачекайте хвилинку та повторіть спробу.",
"ERR_NO_PEERS_TIME": "Немає інших аналогів із відомим часом. Переключіться на «Перейти до мене».",
"ERR_NO_VIDEO_TAB": "Не вдалося підключитися до вкладки відео.",
"SITE_ACCESS_REQUIRED": "Браузер блокує KoalaSync на {host}. Натисніть «Дозволити доступ» нижче. Якщо діалог не з’явиться, дозвольте KoalaSync для цього сайту в налаштуваннях розширення або дозволах сайту.",
"BTN_RETRY_ACCESS": "Дозволити доступ",
"ERR_SELECT_VIDEO": "Спочатку виберіть відео!",
"TOAST_INVITE_COPIED": "Посилання на запрошення скопійовано!",
"TOAST_COPY_FAILED": "Не вдалося скопіювати в буфер обміну",
+2
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "身份尚未加载。稍等片刻,然后重试。",
"ERR_NO_PEERS_TIME": "没有其他已知时间的同行。切换到“跳到我这里”。",
"ERR_NO_VIDEO_TAB": "无法连接到视频选项卡。",
"SITE_ACCESS_REQUIRED": "浏览器正在阻止 KoalaSync 访问 {host}。请点击下方的“允许访问”。如果未显示对话框,请在扩展程序或网站权限中允许 KoalaSync 访问此网站。",
"BTN_RETRY_ACCESS": "允许访问",
"ERR_SELECT_VIDEO": "请先选择视频!",
"TOAST_INVITE_COPIED": "邀请链接已复制!",
"TOAST_COPY_FAILED": "无法复制到剪贴板",
+4 -3
View File
@@ -1,9 +1,10 @@
{
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.6.1",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "2.6.4",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
"tabs",
+8 -72
View File
@@ -1,20 +1,7 @@
export function initTabManager({
getCurrentTabId,
setCurrentTabId,
setCurrentTabTitle,
setLastContentHeartbeatAt,
setRoomIdleSince,
getCurrentRoom,
getPeerId,
getStorageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
injectContentScript,
ensureState,
EVENTS
reactivateCurrentTarget,
ensureState
}) {
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
@@ -28,65 +15,14 @@ export function initTabManager({
}).catch(() => {});
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
if (tabId === getCurrentTabId()) {
const wasInRoom = !!getCurrentRoom();
setCurrentTabId(null);
setCurrentTabTitle(null);
setLastContentHeartbeatAt(null);
const now = Date.now();
setRoomIdleSince(now);
chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince: now,
lastContentHeartbeatAt: null
});
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
if (wasInRoom) {
const roomAtClose = getCurrentRoom();
getSettings().then(settings => {
if (getCurrentRoom() !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId: getPeerId(),
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
const room = getCurrentRoom();
if (room && Array.isArray(room.peers)) {
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (getStorageInitialized()) {
chrome.storage.session.set({ currentRoom: room });
}
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
await ensureState();
const curTabId = getCurrentTabId();
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
injectContentScript(tabId)
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
const currentTabId = Number(getCurrentTabId());
if (Number.isInteger(currentTabId)
&& currentTabId > 0
&& tabId === currentTabId
&& changeInfo.status === 'complete') {
reactivateCurrentTarget(tabId).catch(() => {});
}
});
}
+22
View File
@@ -453,6 +453,24 @@
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08);
}
.site-access-notice {
display: none;
margin-top: 8px;
margin-bottom: 0;
border-left: 4px solid var(--warning);
background: color-mix(in oklch, var(--warning), var(--card) 90%);
color: var(--text);
font-size: 11px;
line-height: 1.45;
}
.site-access-notice button {
width: auto;
margin-top: 8px;
padding: 6px 10px;
font-size: 11px;
}
.peer-item {
padding: 10px 0;
border-bottom: 1px solid var(--border-strong);
@@ -1432,6 +1450,10 @@
Select your video here!
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
</div>
<div id="siteAccessNotice" class="info-card site-access-notice" role="alert" aria-live="polite">
<div id="siteAccessMessage"></div>
<button id="siteAccessRetry" class="secondary" data-i18n="BTN_RETRY_ACCESS">Try again</button>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
+216 -28
View File
@@ -5,6 +5,7 @@ import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.
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';
let pendingInviteRoomId = '';
let pendingInviteChatKey = '';
@@ -19,6 +20,9 @@ const elements = {
contents: document.querySelectorAll('.tab-content'),
copyInvite: document.getElementById('copyInvite'),
targetTab: document.getElementById('targetTab'),
siteAccessNotice: document.getElementById('siteAccessNotice'),
siteAccessMessage: document.getElementById('siteAccessMessage'),
siteAccessRetry: document.getElementById('siteAccessRetry'),
forceSyncBtn: document.getElementById('forceSyncBtn'),
forceSyncMode: document.getElementById('forceSyncMode'),
peerList: document.getElementById('peerList'),
@@ -396,7 +400,7 @@ async function init() {
// Initial Status Check (status shows via GET_STATUS below)
// Initial Status Check
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
chrome.runtime.sendMessage({ type: 'GET_STATUS', retryPendingTarget: true }, async (res) => {
if (chrome.runtime.lastError) {
console.warn('[Popup] Background not responding:', chrome.runtime.lastError.message);
await populateTabs();
@@ -419,13 +423,24 @@ async function init() {
applyConnectionStatus('connecting');
}
// Populate Tabs using the background's targetTabId
await populateTabs(res.peers, res.targetTabId);
if (res.pendingTargetTabId) {
showSiteAccessNotice(
res.pendingTargetHost,
res.pendingTargetOriginPattern,
res.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
// Keep a denied selection visible while Chrome waits for the user
// to grant access; it becomes active automatically after approval.
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
// Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
if (res.status === 'connected' && !res.targetTabId && localData.roomId) {
if (res.status === 'connected' && !res.targetTabId && !res.pendingTargetTabId && localData.roomId) {
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
@@ -472,6 +487,10 @@ function toggleUIState(inRoom) {
// --- Host Control Mode UI ---
// True when we're a guest in a host-only room → remote-control buttons are locked.
let hcmGuestLocked = false;
let siteAccessBlocked = false;
let siteAccessHost = null;
let siteAccessOriginPattern = null;
let siteAccessTabId = null;
// Co-Host state mirrored for the peer-list renderer (promote/demote + role badges).
let hcmAmOwner = false;
@@ -533,12 +552,15 @@ function updateHostControlUI(state) {
}
function setRemoteControlsLocked(locked) {
const effectiveLocked = locked || siteAccessBlocked;
[elements.playBtn, elements.pauseBtn, elements.forceSyncBtn].forEach(btn => {
if (!btn) return;
btn.disabled = locked;
btn.style.opacity = locked ? '0.5' : '';
btn.style.cursor = locked ? 'not-allowed' : '';
btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '';
btn.disabled = effectiveLocked;
btn.style.opacity = effectiveLocked ? '0.5' : '';
btn.style.cursor = effectiveLocked ? 'not-allowed' : '';
btn.title = siteAccessBlocked
? (elements.siteAccessMessage?.textContent || getMessage('ERR_NO_VIDEO_TAB'))
: (locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '');
});
// Always restore the default labels. The action handlers leave the text in a
// transitional state ("Playing..." / "Pausing...") and the 2.5s safety reset
@@ -549,6 +571,88 @@ function setRemoteControlsLocked(locked) {
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
}
function showSiteAccessNotice(host, originPattern = null, tabId = null) {
siteAccessBlocked = true;
siteAccessHost = typeof host === 'string' && host.length > 0 ? host : 'website';
siteAccessOriginPattern = typeof originPattern === 'string' && originPattern.length > 0
? originPattern
: null;
siteAccessTabId = normalizeTabId(tabId);
if (elements.siteAccessMessage) {
elements.siteAccessMessage.textContent = getMessage('SITE_ACCESS_REQUIRED', {
host: siteAccessHost
});
}
if (elements.siteAccessRetry) {
elements.siteAccessRetry.disabled = !siteAccessOriginPattern || siteAccessTabId === null;
}
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'block';
setRemoteControlsLocked(hcmGuestLocked);
}
function hideSiteAccessNotice() {
siteAccessBlocked = false;
siteAccessHost = null;
siteAccessOriginPattern = null;
siteAccessTabId = null;
if (elements.siteAccessRetry) elements.siteAccessRetry.disabled = false;
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'none';
setRemoteControlsLocked(hcmGuestLocked);
}
function handleTargetTabResponse(response) {
if (response?.status === 'host_permission_required') {
showSiteAccessNotice(response.host, response.originPattern, response.tabId);
return false;
}
if (response?.status === 'ok') {
hideSiteAccessNotice();
return true;
}
if (response?.status === 'superseded') return false;
showToast(response?.message || getMessage('ERR_NO_VIDEO_TAB'), 'error', 5000);
return false;
}
function selectTargetTab(tabId, tabTitle) {
return new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => {
if (chrome.runtime.lastError) {
showToast(chrome.runtime.lastError.message || getMessage('ERR_NO_VIDEO_TAB'), 'error', 5000);
resolve(false);
return;
}
resolve(handleTargetTabResponse(response));
});
});
}
async function refreshTargetAccessState() {
const status = await new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, response => {
if (chrome.runtime.lastError) {
resolve(null);
return;
}
resolve(response || null);
});
});
if (!status) return;
if (status.pendingTargetTabId) {
showSiteAccessNotice(
status.pendingTargetHost,
status.pendingTargetOriginPattern,
status.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
await populateTabs(
status.peers,
status.targetTabId ?? status.pendingTargetTabId ?? null
);
}
if (elements.hostControlToggle) {
elements.hostControlToggle.addEventListener('change', () => {
const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone';
@@ -1012,7 +1116,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
if (populateTabsToken !== token) return;
currentTargetTabId = null;
} else {
currentTargetTabId = status?.targetTabId;
currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId;
}
}
@@ -1132,7 +1236,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
if (matchOpt && elements.targetTab.options.length > 1) {
elements.targetTab.value = matchOpt.value;
const tabTitle = matchOpt.dataset.originalTitle || null;
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: parseInt(matchOpt.value), tabTitle });
selectTargetTab(parseInt(matchOpt.value), tabTitle);
}
}
}
@@ -1465,7 +1569,16 @@ if (elements.langSelector) {
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
await populateTabs(res.peers, res.targetTabId);
if (res.pendingTargetTabId) {
showSiteAccessNotice(
res.pendingTargetHost,
res.pendingTargetOriginPattern,
res.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
applyConnectionStatus('disconnected');
@@ -1766,18 +1879,48 @@ elements.retryBtn.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
});
elements.targetTab.addEventListener('change', () => {
elements.targetTab.addEventListener('change', async () => {
const hint = document.getElementById('targetTabHint');
if (hint) hint.style.display = 'none';
const val = elements.targetTab.value;
const tabId = val ? parseInt(val) : null;
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle });
await selectTargetTab(tabId, tabTitle);
});
if (elements.siteAccessRetry) {
elements.siteAccessRetry.addEventListener('click', async () => {
const tabId = normalizeTabId(elements.targetTab.value);
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
if (tabId === null || siteAccessTabId !== tabId || !siteAccessOriginPattern) {
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
return;
}
const requestedOriginPattern = siteAccessOriginPattern;
const requestedTabId = siteAccessTabId;
elements.siteAccessRetry.disabled = true;
elements.targetTab.disabled = true;
try {
const granted = await requestOriginPermission(chrome, requestedOriginPattern);
if (granted !== true) return;
const selectedTabId = normalizeTabId(elements.targetTab.value);
// permissions.onAdded may already have completed activation, or a
// newer selection may have replaced this request while it was open.
if (!siteAccessBlocked || selectedTabId !== tabId
|| siteAccessTabId !== requestedTabId
|| siteAccessOriginPattern !== requestedOriginPattern) return;
await selectTargetTab(tabId, tabTitle);
} finally {
elements.siteAccessRetry.disabled = siteAccessBlocked
&& (!siteAccessOriginPattern || siteAccessTabId === null);
elements.targetTab.disabled = false;
}
});
}
elements.forceSyncBtn.addEventListener('click', async () => {
if (hcmGuestLocked) return; // guest in host-only room — backstop (M-2/L-3)
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
if (elements.forceSyncBtn.disabled) return;
const originalText = elements.forceSyncBtn.textContent;
@@ -1788,6 +1931,11 @@ elements.forceSyncBtn.addEventListener('click', async () => {
elements.forceSyncBtn.disabled = false;
return;
}
const tabId = normalizeTabId(status.targetTabId);
if (tabId === null) {
elements.forceSyncBtn.disabled = false;
return;
}
const mode = elements.forceSyncMode.value;
let targetTime = null;
@@ -1823,23 +1971,33 @@ elements.forceSyncBtn.addEventListener('click', async () => {
// Don't unlock a button that's locked because we became a guest mid-flight —
// hcmGuestLocked is the source of truth for the lock state, and the next
// CONTROL_MODE update restores the correct label.
if (!forceSyncDone && !hcmGuestLocked) {
if (!forceSyncDone && !hcmGuestLocked && !siteAccessBlocked) {
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
}
};
forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs);
const tabId = parseInt(status.targetTabId);
const failForceSyncTime = () => {
if (forceSyncDone) return;
if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; }
showError(getMessage('ERR_NO_VIDEO_TAB'));
forceSyncDone = true;
elements.forceSyncBtn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
};
const sendForceSync = (time) => {
const targetIsStillCurrent = () => new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, currentStatus => {
if (chrome.runtime.lastError) {
resolve(false);
return;
}
resolve(normalizeTabId(currentStatus?.targetTabId) === tabId);
});
});
const sendForceSync = async (time) => {
if (time === null || time === undefined) {
failForceSyncTime();
return;
@@ -1849,15 +2007,28 @@ elements.forceSyncBtn.addEventListener('click', async () => {
failForceSyncTime();
return;
}
if (!(await targetIsStillCurrent())) {
failForceSyncTime();
return;
}
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
action: EVENTS.FORCE_SYNC_PREPARE,
expectedTabId: tabId,
payload: { targetTime: target }
}, response => {
if (chrome.runtime.lastError || response?.status === 'stale_target') {
failForceSyncTime();
}
});
};
if (mode === 'jump-to-me') {
const retryQueryTime = () => {
const retryQueryTime = async () => {
if (!(await targetIsStillCurrent())) {
failForceSyncTime();
return;
}
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
failForceSyncTime();
@@ -1872,8 +2043,19 @@ elements.forceSyncBtn.addEventListener('click', async () => {
return;
}
if (chrome.runtime.lastError || !response) {
chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => {
chrome.runtime.sendMessage({
type: 'INJECT_CONTENT_SCRIPT',
tabId,
expectedCurrentTabId: tabId
}, (injectResponse) => {
if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') {
if (injectResponse?.status === 'host_permission_required') {
showSiteAccessNotice(
injectResponse.host,
injectResponse.originPattern,
injectResponse.tabId
);
}
failForceSyncTime();
return;
}
@@ -1889,7 +2071,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
});
elements.playBtn.addEventListener('click', () => {
if (hcmGuestLocked) return; // guest in host-only room — backstop
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
if (!elements.targetTab.value) {
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
return;
@@ -1903,12 +2085,12 @@ elements.playBtn.addEventListener('click', () => {
}, (response) => {
if (response && response.status === 'ok_solo') {
elements.playBtn.textContent = getMessage('BTN_PLAY');
elements.playBtn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) elements.playBtn.disabled = false;
}
});
// Safety reset: restore button after 2.5s in case no peers respond
setTimeout(() => {
if (elements.playBtn.disabled && !hcmGuestLocked) {
if (elements.playBtn.disabled && !hcmGuestLocked && !siteAccessBlocked) {
elements.playBtn.textContent = getMessage('BTN_PLAY');
elements.playBtn.disabled = false;
}
@@ -1916,7 +2098,7 @@ elements.playBtn.addEventListener('click', () => {
});
elements.pauseBtn.addEventListener('click', () => {
if (hcmGuestLocked) return; // guest in host-only room — backstop
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
if (!elements.targetTab.value) {
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
return;
@@ -1930,12 +2112,12 @@ elements.pauseBtn.addEventListener('click', () => {
}, (response) => {
if (response && response.status === 'ok_solo') {
elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
elements.pauseBtn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) elements.pauseBtn.disabled = false;
}
});
// Safety reset: restore button after 2.5s in case no peers respond
setTimeout(() => {
if (elements.pauseBtn.disabled && !hcmGuestLocked) {
if (elements.pauseBtn.disabled && !hcmGuestLocked && !siteAccessBlocked) {
elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
elements.pauseBtn.disabled = false;
}
@@ -2100,7 +2282,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (state.acks && state.acks.length >= peerCount) {
btn.textContent = getMessage('BTN_STATE_SYNCED');
setTimeout(() => {
btn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) btn.disabled = false;
btn.textContent = state.action === 'play' ? getMessage('BTN_PLAY') : getMessage('BTN_PAUSE');
}, 2000);
}
@@ -2115,7 +2297,7 @@ chrome.runtime.onMessage.addListener((msg) => {
forceSyncResetTimer = null;
}
if (elements.forceSyncBtn) {
elements.forceSyncBtn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = getMessage('BTN_STATE_SYNCED');
setTimeout(() => {
elements.forceSyncBtn.textContent = getMessage('BTN_SYNC');
@@ -2167,6 +2349,12 @@ chrome.runtime.onMessage.addListener((msg) => {
}
});
}
} else if (msg.type === 'TARGET_TAB_READY') {
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') {
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'TARGET_TAB_CLEARED') {
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'PING_UPDATE') {
updatePingDisplay(msg.ping);
} else if (msg.type === 'HISTORY_UPDATE') {