mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Fix blocked website access and add troubleshooting page
This commit is contained in:
+246
-55
@@ -4,6 +4,7 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
|
||||
import './page-api-seek-overrides.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
@@ -61,6 +62,7 @@ let peerId = null; // initialized via getPeerId()
|
||||
let currentRoom = null;
|
||||
let currentTabId = null;
|
||||
let currentTabTitle = null; // New: for Smart Matching
|
||||
let targetActivationGeneration = 0;
|
||||
let logs = [];
|
||||
let history = []; // New: for Action History
|
||||
let storageInitialized = false;
|
||||
@@ -517,13 +519,20 @@ function markRoomPotentiallyIdle() {
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle() {
|
||||
targetActivationGeneration++;
|
||||
clearPendingTarget().catch(() => {});
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
}
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
||||
chrome.storage.session.set({
|
||||
currentTabId,
|
||||
currentTabTitle,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt
|
||||
}).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
@@ -544,11 +553,13 @@ 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();
|
||||
targetActivationGeneration++;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
clearEpisodeLobbyState();
|
||||
await clearPendingTarget();
|
||||
await chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
currentTabId: null,
|
||||
@@ -1734,47 +1745,208 @@ 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 } = {}) {
|
||||
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 await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['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.
|
||||
if (access?.originPattern && access.granted === false) {
|
||||
const requestAdded = requestHostAccess
|
||||
? await addTabHostAccessRequest(chrome, tabId)
|
||||
: false;
|
||||
throw createHostAccessRequiredError(access, requestAdded, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function rememberPendingTarget(tabId, tabTitle, error) {
|
||||
const previous = await chrome.storage.session.get('pendingTargetTabId');
|
||||
const previousTabId = normalizeTabId(previous.pendingTargetTabId);
|
||||
if (previousTabId !== null && previousTabId !== tabId) {
|
||||
await removeTabHostAccessRequest(chrome, previousTabId);
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
pendingTargetTabId: tabId,
|
||||
pendingTargetTabTitle: tabTitle || null,
|
||||
pendingTargetHost: error?.host || null,
|
||||
pendingTargetOriginPattern: error?.originPattern || null
|
||||
});
|
||||
}
|
||||
|
||||
async function clearPendingTarget() {
|
||||
const pending = await chrome.storage.session.get('pendingTargetTabId');
|
||||
const pendingTabId = normalizeTabId(pending.pendingTargetTabId);
|
||||
if (pendingTabId !== null) {
|
||||
await removeTabHostAccessRequest(chrome, pendingTabId);
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
pendingTargetTabId: null,
|
||||
pendingTargetTabTitle: null,
|
||||
pendingTargetHost: null,
|
||||
pendingTargetOriginPattern: null
|
||||
});
|
||||
}
|
||||
|
||||
async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = {}) {
|
||||
const selectedTabId = normalizeTabId(tabId);
|
||||
if (selectedTabId === null) {
|
||||
return { status: 'invalid_tab' };
|
||||
}
|
||||
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['page-api-seek-overrides.js']
|
||||
const activationGeneration = ++targetActivationGeneration;
|
||||
const previousTabId = currentTabId;
|
||||
|
||||
try {
|
||||
await injectContentScript(selectedTabId, { requestHostAccess });
|
||||
} catch (error) {
|
||||
if (activationGeneration !== targetActivationGeneration) {
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId) {
|
||||
resetAudioProcessingInTab(previousTabId);
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt: null
|
||||
});
|
||||
updateBadgeStatus();
|
||||
|
||||
if (error?.code === HOST_ACCESS_REQUIRED_STATUS) {
|
||||
await rememberPendingTarget(selectedTabId, tabTitle, error);
|
||||
} else {
|
||||
await clearPendingTarget();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (activationGeneration !== targetActivationGeneration) {
|
||||
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
|
||||
currentTabId = selectedTabId;
|
||||
currentTabTitle = tabTitle || null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId && previousTabId !== selectedTabId) {
|
||||
resetAudioProcessingInTab(previousTabId);
|
||||
}
|
||||
await applyAudioSettingsToTab(selectedTabId);
|
||||
await clearPendingTarget();
|
||||
await chrome.storage.session.set({
|
||||
currentTabId,
|
||||
currentTabTitle,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: setPageApiSeekEnabled,
|
||||
args: [pageApiSeekReady]
|
||||
});
|
||||
return chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
updateBadgeStatus();
|
||||
return { status: 'ok', tabId: selectedTabId };
|
||||
}
|
||||
|
||||
async function retryPendingTarget() {
|
||||
const pending = await chrome.storage.session.get([
|
||||
'pendingTargetTabId',
|
||||
'pendingTargetTabTitle'
|
||||
]);
|
||||
const tabId = normalizeTabId(pending.pendingTargetTabId);
|
||||
if (tabId === null) return null;
|
||||
|
||||
try {
|
||||
const response = await activateTargetTab(tabId, pending.pendingTargetTabTitle, {
|
||||
requestHostAccess: false
|
||||
});
|
||||
if (response.status === 'ok') {
|
||||
addLog(`Website access granted; selected tab ${tabId}`, 'success');
|
||||
chrome.runtime.sendMessage({ type: 'TARGET_TAB_READY', tabId }).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) {
|
||||
await clearPendingTarget();
|
||||
addLog(`Pending tab activation failed: ${error.message}`, 'warn');
|
||||
}
|
||||
return injectionFailureResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (chrome.permissions?.onAdded?.addListener) {
|
||||
chrome.permissions.onAdded.addListener(() => {
|
||||
ensureState()
|
||||
.then(() => retryPendingTarget())
|
||||
.catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1948,6 +2120,14 @@ 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 chrome.storage.session.get([
|
||||
'pendingTargetTabId',
|
||||
'pendingTargetHost',
|
||||
'pendingTargetOriginPattern'
|
||||
]);
|
||||
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'));
|
||||
@@ -1959,6 +2139,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
peers: currentRoom ? currentRoom.peers : [],
|
||||
lastActionState,
|
||||
targetTabId: currentTabId,
|
||||
pendingTargetTabId: pendingTarget.pendingTargetTabId || null,
|
||||
pendingTargetHost: pendingTarget.pendingTargetHost || null,
|
||||
pendingTargetOriginPattern: pendingTarget.pendingTargetOriginPattern || null,
|
||||
episodeLobby: episodeLobby,
|
||||
reconnectAttempts,
|
||||
reconnectSlowMode: reconnectFailed,
|
||||
@@ -2059,6 +2242,7 @@ 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();
|
||||
targetActivationGeneration++;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
@@ -2073,6 +2257,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
// Cancel any active episode lobby
|
||||
clearEpisodeLobbyState();
|
||||
await clearPendingTarget();
|
||||
|
||||
chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
@@ -2427,8 +2612,8 @@ 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;
|
||||
}
|
||||
@@ -2437,34 +2622,37 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'ok' });
|
||||
}).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;
|
||||
targetActivationGeneration++;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId) resetAudioProcessingInTab(previousTabId);
|
||||
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);
|
||||
try {
|
||||
const response = await activateTargetTab(message.tabId, message.tabTitle);
|
||||
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' });
|
||||
@@ -2647,7 +2835,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
initTabManager({
|
||||
getCurrentTabId: () => currentTabId,
|
||||
setCurrentTabId: (val) => { currentTabId = val; },
|
||||
setCurrentTabId: (val) => {
|
||||
if (val !== currentTabId) targetActivationGeneration++;
|
||||
currentTabId = val;
|
||||
},
|
||||
setCurrentTabTitle: (val) => { currentTabTitle = val; },
|
||||
setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; },
|
||||
setRoomIdleSince: (val) => { roomIdleSince = val; },
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required';
|
||||
|
||||
export function normalizeTabId(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const tabId = Number(value);
|
||||
return Number.isInteger(tabId) && tabId > 0 ? tabId : null;
|
||||
}
|
||||
|
||||
export function describeTabUrl(rawUrl) {
|
||||
if (typeof rawUrl !== 'string' || !rawUrl) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
return {
|
||||
url: rawUrl,
|
||||
host: url.host,
|
||||
originPattern: `${url.origin}/*`
|
||||
};
|
||||
}
|
||||
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);
|
||||
const descriptor = describeTabUrl(tab?.url || '');
|
||||
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 chromeApi.permissions.contains({
|
||||
origins: [descriptor.originPattern]
|
||||
});
|
||||
return { tab, ...descriptor, granted: 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 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function addTabHostAccessRequest(chromeApi, tabId) {
|
||||
if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await chromeApi.permissions.addHostAccessRequest({ tabId });
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeTabHostAccessRequest(chromeApi, tabId) {
|
||||
if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await chromeApi.permissions.removeHostAccessRequest({ tabId });
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’accès ». Si aucune boîte de dialogue n’apparaît, autorisez KoalaSync sur ce site dans les permissions des extensions ou du site.",
|
||||
"BTN_RETRY_ACCESS": "Autoriser l’accè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",
|
||||
|
||||
@@ -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 dell’estensione 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",
|
||||
|
||||
@@ -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": "クリップボードへのコピーに失敗しました",
|
||||
|
||||
@@ -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": "클립보드 복사에 실패했습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Не удалось скопировать ссылку в буфер обмена",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Не вдалося скопіювати в буфер обміну",
|
||||
|
||||
@@ -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": "无法复制到剪贴板",
|
||||
|
||||
@@ -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;">
|
||||
|
||||
+125
-24
@@ -10,6 +10,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'),
|
||||
@@ -387,7 +390,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();
|
||||
@@ -410,13 +413,20 @@ 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);
|
||||
} 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();
|
||||
@@ -463,6 +473,9 @@ 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;
|
||||
|
||||
// Co-Host state mirrored for the peer-list renderer (promote/demote + role badges).
|
||||
let hcmAmOwner = false;
|
||||
@@ -524,12 +537,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
|
||||
@@ -540,6 +556,64 @@ function setRemoteControlsLocked(locked) {
|
||||
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
|
||||
}
|
||||
|
||||
function showSiteAccessNotice(host, originPattern = null) {
|
||||
siteAccessBlocked = true;
|
||||
siteAccessHost = host || siteAccessHost || 'website';
|
||||
siteAccessOriginPattern = originPattern || siteAccessOriginPattern;
|
||||
if (elements.siteAccessMessage) {
|
||||
elements.siteAccessMessage.textContent = getMessage('SITE_ACCESS_REQUIRED', {
|
||||
host: siteAccessHost
|
||||
});
|
||||
}
|
||||
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'block';
|
||||
setRemoteControlsLocked(hcmGuestLocked);
|
||||
}
|
||||
|
||||
function hideSiteAccessNotice() {
|
||||
siteAccessBlocked = false;
|
||||
siteAccessHost = null;
|
||||
siteAccessOriginPattern = null;
|
||||
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'none';
|
||||
setRemoteControlsLocked(hcmGuestLocked);
|
||||
}
|
||||
|
||||
function handleTargetTabResponse(response) {
|
||||
if (response?.status === 'host_permission_required') {
|
||||
showSiteAccessNotice(response.host, response.originPattern);
|
||||
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;
|
||||
}
|
||||
|
||||
async function requestSiteAccess(originPattern) {
|
||||
if (!originPattern || typeof chrome.permissions?.request !== 'function') return null;
|
||||
try {
|
||||
return await chrome.permissions.request({ origins: [originPattern] });
|
||||
} catch (error) {
|
||||
console.warn('[Popup] Host permission request failed:', error.message);
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.hostControlToggle) {
|
||||
elements.hostControlToggle.addEventListener('change', () => {
|
||||
const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone';
|
||||
@@ -998,7 +1072,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
if (populateTabsToken !== token) return;
|
||||
currentTargetTabId = null;
|
||||
} else {
|
||||
currentTargetTabId = status?.targetTabId;
|
||||
currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,7 +1192,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1471,7 +1545,12 @@ if (elements.langSelector) {
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
|
||||
await populateTabs(res.peers, res.targetTabId);
|
||||
if (res.pendingTargetTabId) {
|
||||
showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern);
|
||||
} else {
|
||||
hideSiteAccessNotice();
|
||||
}
|
||||
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
} else {
|
||||
applyConnectionStatus('disconnected');
|
||||
@@ -1749,18 +1828,34 @@ 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 val = elements.targetTab.value;
|
||||
const tabId = val ? parseInt(val) : null;
|
||||
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
|
||||
if (!tabId) {
|
||||
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
|
||||
return;
|
||||
}
|
||||
elements.siteAccessRetry.disabled = true;
|
||||
await requestSiteAccess(siteAccessOriginPattern);
|
||||
await selectTargetTab(tabId, tabTitle);
|
||||
elements.siteAccessRetry.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;
|
||||
@@ -1806,7 +1901,7 @@ 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;
|
||||
}
|
||||
@@ -1818,7 +1913,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1857,6 +1952,9 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (chrome.runtime.lastError || !response) {
|
||||
chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => {
|
||||
if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') {
|
||||
if (injectResponse?.status === 'host_permission_required') {
|
||||
showSiteAccessNotice(injectResponse.host, injectResponse.originPattern);
|
||||
}
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
@@ -1872,7 +1970,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;
|
||||
@@ -1886,12 +1984,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;
|
||||
}
|
||||
@@ -1899,7 +1997,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;
|
||||
@@ -1913,12 +2011,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;
|
||||
}
|
||||
@@ -2083,7 +2181,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);
|
||||
}
|
||||
@@ -2098,7 +2196,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');
|
||||
@@ -2150,6 +2248,9 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'TARGET_TAB_READY') {
|
||||
hideSiteAccessNotice();
|
||||
populateTabs(null, msg.tabId);
|
||||
} else if (msg.type === 'PING_UPDATE') {
|
||||
updatePingDisplay(msg.ping);
|
||||
} else if (msg.type === 'HISTORY_UPDATE') {
|
||||
|
||||
Reference in New Issue
Block a user