mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 142153a131 | |||
| a334605dd5 | |||
| 7e7e60f267 | |||
| ab2d7747fd | |||
| 8f9676cc5a | |||
| 477a814a79 | |||
| cb84709358 | |||
| 43a7bb0d57 | |||
| e1e86307b1 | |||
| e934fcf484 | |||
| d4181b2eaf |
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.5.4-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.1-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.5.4 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.1 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
|
||||
+23
-10
@@ -1,29 +1,42 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension, relay server, website, and release tooling.
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.6.2] — 2026-07-15
|
||||
|
||||
### Added
|
||||
- **Website: Site-access recovery guide** — Added an English help page and a localized banner across every landing-page language.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Withheld website access recovery** — Detects browser-withheld host access, shows a localized allow-access action, requests only the selected website origin, and resumes the selected tab after access is granted.
|
||||
- **Extension: Target-tab reliability** — Hardened permission recovery against rapid tab changes, navigation, closed tabs, service-worker restoration, and stale pending state.
|
||||
- **Browser compatibility** — Uses Chrome's toolbar access request only when available, with a direct user-gesture permission fallback for Firefox and older Chromium browsers.
|
||||
|
||||
---
|
||||
|
||||
## [v2.6.1] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
- **Extension: Episode Lobby peer list** — Peer names in the lobby are now rendered as text instead of markup. A peer could previously put HTML in their username and have it rendered in everyone else's popup, which allowed loading remote images (leaking viewer IP addresses) and spoofing the readiness badges. Scripts were already blocked by the extension's content security policy.
|
||||
|
||||
### Changed
|
||||
- **Release checks: AMO validation** — `npm run verify` now runs Mozilla's `addons-linter` against the built Firefox artifact with `--warnings-as-errors`, and ESLint enforces `no-unsanitized`, so upload-blocking issues surface locally instead of at submission time.
|
||||
|
||||
---
|
||||
|
||||
## [v2.6.0] — 2026-07-15
|
||||
|
||||
### Added
|
||||
- **Website: Expanded comparison guides** — Added fully localized comparison pages for Kosmi, Scener, and TwoSeven, plus improved existing alternatives pages and navigation.
|
||||
- **Website: AI-readable product profile** — Expanded `llms.txt` with fit guidance, limitations, architecture, privacy, installation, self-hosting, and support information, and linked it from every localized landing-page head.
|
||||
- **Website: Accessibility regression coverage** — Added automated checks for light-theme step numbers, legal-link cues, heading hierarchy, readable self-hosting surfaces, and reduced-motion behavior.
|
||||
- **Extension: Appearance controls** — Added localized system, light, and dark theme options with an early theme initializer to avoid flashes during popup startup.
|
||||
|
||||
### Changed
|
||||
- **Website: Koala nature redesign** — Rebuilt the landing page around a responsive day/night forest theme with richer motion, an improved interactive demo, clearer onboarding, polished legal/join/error pages, and stronger mobile behavior.
|
||||
- **Extension: Popup and settings redesign** — Unified controls, status surfaces, colors, icons, badges, and accessibility behavior; settings are now organized into mutually exclusive accordion groups.
|
||||
- **Website: Faster rendering and builds** — Split page CSS by responsibility, removed startup forced reflows, added responsive mascot assets, cached AVIF generation, subset the flag font, minified generated HTML, and added integrity metadata for scripts and styles.
|
||||
- **Website: SEO and localization** — Added richer structured metadata, AI-crawler guidance, dynamic sitemap generation, and complete translations for the expanded alternatives content.
|
||||
- **Website: Safer self-hosting example** — Added an explicit `SERVER_SALT` replacement value, optional admin-metrics configuration, loopback-only relay publishing, and consistent host-Caddy proxy targets without requiring a separate `.env` file.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Episode Lobby reliability** — Prevented the lobby from remaining stuck in a loading state and stopped episode transitions from triggering on non-episodic media.
|
||||
- **Extension: Tab-title normalization** — Notification counters such as `(14)`, `[7]`, and `(99+)` are removed reliably without stripping legitimate large numeric titles.
|
||||
- **Website and extension: Theme contrast and navigation** — Fixed light-theme contrast, language highlighting, mobile join navigation, and several control-label and tooltip inconsistencies.
|
||||
- **Website: Build and accessibility correctness** — Replaced the regex HTML minifier with conservative parser-based `htmlnano`, stopped tracking generated sitemap output, restored readable light-theme step numbers, fixed legal-page and comparison-card contrast, repaired heading order, and disabled all film motion when reduced motion is requested.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import noUnsanitized from "eslint-plugin-no-unsanitized";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**", "website/www/**"]
|
||||
@@ -45,7 +47,14 @@ export default [
|
||||
process: "readonly",
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
"no-unsanitized": noUnsanitized
|
||||
},
|
||||
rules: {
|
||||
// Mirrors the AMO validator's "Unsafe assignment to innerHTML" check, so a
|
||||
// rejection at upload time surfaces here instead.
|
||||
"no-unsanitized/property": "error",
|
||||
"no-unsanitized/method": "error",
|
||||
"no-undef": "error",
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }],
|
||||
"no-unreachable": "error",
|
||||
|
||||
+343
-59
@@ -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, isHostAccessError, 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;
|
||||
@@ -168,8 +170,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);
|
||||
@@ -517,13 +523,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 +557,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 +1749,296 @@ 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 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.
|
||||
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();
|
||||
|
||||
function mutatePendingTarget(operation) {
|
||||
const result = pendingTargetMutation.catch(() => {}).then(operation);
|
||||
pendingTargetMutation = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function rememberPendingTarget(tabId, tabTitle, error) {
|
||||
return mutatePendingTarget(async () => {
|
||||
const previous = await chrome.storage.session.get([
|
||||
'pendingTargetTabId',
|
||||
'pendingTargetOriginPattern'
|
||||
]);
|
||||
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
|
||||
);
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
pendingTargetTabId: tabId,
|
||||
pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null,
|
||||
pendingTargetHost: error?.host || null,
|
||||
pendingTargetOriginPattern: nextOriginPattern
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function clearPendingTarget() {
|
||||
return mutatePendingTarget(async () => {
|
||||
const pending = await chrome.storage.session.get([
|
||||
'pendingTargetTabId',
|
||||
'pendingTargetOriginPattern'
|
||||
]);
|
||||
const pendingTabId = normalizeTabId(pending.pendingTargetTabId);
|
||||
if (pendingTabId !== null) {
|
||||
await removeTabHostAccessRequest(
|
||||
chrome,
|
||||
pendingTabId,
|
||||
pending.pendingTargetOriginPattern || null
|
||||
);
|
||||
}
|
||||
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 = normalizeTabId(currentTabId);
|
||||
|
||||
try {
|
||||
await injectContentScript(selectedTabId, { requestHostAccess });
|
||||
} catch (error) {
|
||||
if (activationGeneration !== targetActivationGeneration) {
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId) {
|
||||
resetAudioProcessingInTab(previousTabId);
|
||||
}
|
||||
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) {
|
||||
await rememberPendingTarget(selectedTabId, tabTitle, error);
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'TARGET_TAB_ACCESS_REQUIRED',
|
||||
...injectionFailureResponse(error)
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
await clearPendingTarget();
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
currentTabId,
|
||||
currentTabTitle,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: setPageApiSeekEnabled,
|
||||
args: [pageApiSeekReady]
|
||||
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'));
|
||||
});
|
||||
return chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}
|
||||
|
||||
if (chrome.tabs?.onRemoved?.addListener) {
|
||||
chrome.tabs.onRemoved.addListener((removedTabId) => {
|
||||
ensureState().then(async () => {
|
||||
const tabId = normalizeTabId(removedTabId);
|
||||
if (tabId === null) return;
|
||||
const pending = await chrome.storage.session.get('pendingTargetTabId');
|
||||
const isCurrent = normalizeTabId(currentTabId) === tabId;
|
||||
const isPending = normalizeTabId(pending.pendingTargetTabId) === tabId;
|
||||
if (!isCurrent && !isPending) return;
|
||||
|
||||
targetActivationGeneration++;
|
||||
if (isCurrent) {
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
}
|
||||
if (isPending) await clearPendingTarget();
|
||||
await chrome.storage.session.set({
|
||||
currentTabId,
|
||||
currentTabTitle,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt
|
||||
});
|
||||
updateBadgeStatus();
|
||||
chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {});
|
||||
}).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1792,7 +2056,8 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
|
||||
return;
|
||||
}
|
||||
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
|
||||
injectContentScript(tabId).then(() => {
|
||||
activateTargetTab(tabId, currentTabTitle).then(response => {
|
||||
if (response?.status !== 'ok') return;
|
||||
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
|
||||
}).catch(_err => {
|
||||
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
|
||||
@@ -1948,6 +2213,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 +2232,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 +2335,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 +2350,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
// Cancel any active episode lobby
|
||||
clearEpisodeLobbyState();
|
||||
await clearPendingTarget();
|
||||
|
||||
chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
@@ -2427,44 +2705,47 @@ 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' });
|
||||
activateTargetTab(tabId, currentTabTitle).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;
|
||||
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 +2928,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,168 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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?.pendingUrl || 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 callBooleanPermissionMethod(
|
||||
chromeApi,
|
||||
'contains',
|
||||
{ origins: [descriptor.originPattern] }
|
||||
);
|
||||
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) {
|
||||
const method = chromeApi.permissions?.[methodName];
|
||||
if (typeof method !== 'function') return Promise.resolve(null);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value === true);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
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);
|
||||
};
|
||||
|
||||
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] }
|
||||
).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;
|
||||
}
|
||||
}
|
||||
@@ -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": "无法复制到剪贴板",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.5.4",
|
||||
"version": "2.6.1",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
+24
-2
@@ -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);
|
||||
@@ -1146,12 +1164,12 @@
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.tab-btn[data-tab="tab-dev"],
|
||||
/* Only the opt-in Dev tab is de-emphasized. tab-dev is the user-facing
|
||||
"Status" tab and must match the other tabs. */
|
||||
#devToolsTabBtn {
|
||||
color: color-mix(in oklch, var(--text-muted), transparent 18%);
|
||||
font-size: 11px;
|
||||
}
|
||||
.tab-btn[data-tab="tab-dev"].active,
|
||||
#devToolsTabBtn.active {
|
||||
color: var(--text-on-green);
|
||||
}
|
||||
@@ -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;">
|
||||
|
||||
+153
-29
@@ -3,6 +3,7 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
||||
import { requestOriginPermission } from './host-access.js';
|
||||
|
||||
|
||||
const elements = {
|
||||
@@ -10,6 +11,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 +391,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 +414,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 +474,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 +538,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 +557,54 @@ 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;
|
||||
}
|
||||
|
||||
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 +1063,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 +1183,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 +1536,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 +1819,45 @@ 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;
|
||||
}
|
||||
const requestedOriginPattern = siteAccessOriginPattern;
|
||||
elements.siteAccessRetry.disabled = true;
|
||||
elements.targetTab.disabled = true;
|
||||
try {
|
||||
await requestOriginPermission(chrome, requestedOriginPattern);
|
||||
const selectedTabId = parseInt(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
|
||||
|| siteAccessOriginPattern !== requestedOriginPattern) return;
|
||||
await selectTargetTab(tabId, tabTitle);
|
||||
} finally {
|
||||
elements.siteAccessRetry.disabled = false;
|
||||
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;
|
||||
@@ -1806,7 +1903,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 +1915,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 +1954,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 +1972,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 +1986,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 +1999,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 +2013,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 +2183,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 +2198,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 +2250,15 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'TARGET_TAB_READY') {
|
||||
hideSiteAccessNotice();
|
||||
populateTabs(null, msg.tabId);
|
||||
} else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') {
|
||||
showSiteAccessNotice(msg.host, msg.originPattern);
|
||||
populateTabs(null, msg.tabId);
|
||||
} else if (msg.type === 'TARGET_TAB_CLEARED') {
|
||||
hideSiteAccessNotice();
|
||||
populateTabs();
|
||||
} else if (msg.type === 'PING_UPDATE') {
|
||||
updatePingDisplay(msg.ping);
|
||||
} else if (msg.type === 'HISTORY_UPDATE') {
|
||||
@@ -2603,9 +2712,10 @@ function updateLobbyUI(lobby, peers) {
|
||||
elements.episodeLobbyCard.style.display = 'block';
|
||||
elements.lobbyTitle.textContent = getMessage('LOBBY_WAITING_FOR', { title: lobby.expectedTitle });
|
||||
|
||||
// Build peer readiness list
|
||||
// Build peer readiness list. Peer usernames are remote-controlled input, so
|
||||
// every node is built with the DOM API and filled via textContent.
|
||||
const readySet = new Set(lobby.readyPeers || []);
|
||||
const peerHtmls = [];
|
||||
const peerItems = [];
|
||||
|
||||
if (peers && peers.length > 0) {
|
||||
peers.forEach(p => {
|
||||
@@ -2616,12 +2726,26 @@ function updateLobbyUI(lobby, peers) {
|
||||
const icon = isReady ? '✅' : '⏳';
|
||||
const label = isReady ? getMessage('LABEL_LOBBY_PEER_READY') : getMessage('LABEL_LOBBY_PEER_LOADING');
|
||||
const badgeClass = isReady ? 'badge-ready' : 'badge-loading';
|
||||
peerHtmls.push(`<span class="lobby-peer-item">${icon} ${avatar} ${pName} <span class="badge ${badgeClass}">${label}</span></span>`);
|
||||
|
||||
const item = document.createElement('span');
|
||||
item.className = 'lobby-peer-item';
|
||||
item.appendChild(document.createTextNode(`${icon} ${avatar} ${pName} `));
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `badge ${badgeClass}`;
|
||||
badge.textContent = label;
|
||||
item.appendChild(badge);
|
||||
|
||||
peerItems.push(item);
|
||||
});
|
||||
}
|
||||
|
||||
if (peerHtmls.length > 0 && elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.innerHTML = peerHtmls.join(' ');
|
||||
if (peerItems.length > 0 && elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = '';
|
||||
peerItems.forEach((item, index) => {
|
||||
if (index > 0) elements.lobbyPeerStatus.appendChild(document.createTextNode(' '));
|
||||
elements.lobbyPeerStatus.appendChild(item);
|
||||
});
|
||||
} else if (elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = getMessage('LOBBY_WAITING_PEERS');
|
||||
}
|
||||
|
||||
Generated
+1500
-13
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.5.4",
|
||||
"version": "2.6.1",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -17,9 +17,11 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"addons-linter": "^10.9.0",
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-no-unsanitized": "^4.1.5",
|
||||
"fs-extra": "^11.2.0",
|
||||
"htmlnano": "^3.4.0",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cwd } from 'node:process';
|
||||
import {
|
||||
HOST_ACCESS_REQUIRED_STATUS,
|
||||
addTabHostAccessRequest,
|
||||
describeTabUrl,
|
||||
inspectTabHostAccess,
|
||||
isHostAccessError,
|
||||
normalizeTabId,
|
||||
removeTabHostAccessRequest,
|
||||
requestOriginPermission
|
||||
} from '../extension/host-access.js';
|
||||
|
||||
assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required');
|
||||
assert.equal(normalizeTabId(null), null);
|
||||
assert.equal(normalizeTabId(undefined), null);
|
||||
assert.equal(normalizeTabId(''), null);
|
||||
assert.equal(normalizeTabId(0), null);
|
||||
assert.equal(normalizeTabId('42'), 42);
|
||||
assert.equal(normalizeTabId(true), null);
|
||||
assert.equal(normalizeTabId([42]), null);
|
||||
assert.equal(normalizeTabId('42.5'), null);
|
||||
assert.equal(normalizeTabId(' 42 '), 42);
|
||||
assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null);
|
||||
assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), {
|
||||
url: 'https://emby.example:8443/web/index.html',
|
||||
host: 'emby.example:8443',
|
||||
originPattern: 'https://emby.example:8443/*'
|
||||
});
|
||||
assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), {
|
||||
url: 'http://localhost:8096/web/',
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost:8096/*'
|
||||
});
|
||||
assert.equal(describeTabUrl('chrome://extensions/'), null);
|
||||
assert.equal(describeTabUrl('not a url'), null);
|
||||
|
||||
let containsRequest = null;
|
||||
const deniedChrome = {
|
||||
tabs: {
|
||||
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
|
||||
},
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
const access = await inspectTabHostAccess(deniedChrome, 42);
|
||||
assert.equal(access.granted, false);
|
||||
assert.equal(access.host, 'video.example');
|
||||
assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
|
||||
|
||||
let requestedTabId = null;
|
||||
const requestChrome = {
|
||||
permissions: {
|
||||
addHostAccessRequest: async request => { requestedTabId = request; }
|
||||
}
|
||||
};
|
||||
assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true);
|
||||
assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' });
|
||||
assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false);
|
||||
|
||||
let removedTabId = null;
|
||||
const removeRequestChrome = {
|
||||
permissions: {
|
||||
removeHostAccessRequest: async request => { removedTabId = request; }
|
||||
}
|
||||
};
|
||||
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true);
|
||||
assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' });
|
||||
assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false);
|
||||
|
||||
assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true);
|
||||
assert.equal(isHostAccessError(new Error('No tab with id: 42')), false);
|
||||
const callbackPermissionChrome = {
|
||||
runtime: {},
|
||||
permissions: {
|
||||
request: (_request, callback) => { callback(true); }
|
||||
}
|
||||
};
|
||||
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
|
||||
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
|
||||
|
||||
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
|
||||
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
|
||||
|
||||
assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/,
|
||||
'SET_TARGET_TAB must await successful activation before acknowledging it');
|
||||
assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/,
|
||||
'failed injection must register Chrome host-access request');
|
||||
assert.match(background, /retryPendingTarget\(\)/,
|
||||
'pending target must resume after the user grants access');
|
||||
assert.match(background, /activationGeneration !== targetActivationGeneration/,
|
||||
'stale concurrent tab activations must not overwrite the newest selection');
|
||||
const activateTargetBody = background.slice(
|
||||
background.indexOf('async function activateTargetTab'),
|
||||
background.indexOf('async function retryPendingTarget')
|
||||
);
|
||||
assert.ok(
|
||||
activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'),
|
||||
'a tab must not become current until its content script injection succeeds'
|
||||
);
|
||||
assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/,
|
||||
'clearing a pending target must also clear Chrome toolbar access requests');
|
||||
assert.match(popup, /response\?\.status === 'host_permission_required'/,
|
||||
'popup must render the structured host-access failure');
|
||||
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
|
||||
'retry button must request withheld host access directly');
|
||||
assert.match(popupHtml, /id="siteAccessNotice"/,
|
||||
'popup must contain a persistent site-access notice');
|
||||
|
||||
console.log('host access recovery tests passed');
|
||||
@@ -20,6 +20,7 @@ const checks = [
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
|
||||
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
|
||||
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
|
||||
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
|
||||
['server syntax index', 'node', ['-c', 'server/index.js']],
|
||||
['server syntax ops', 'node', ['-c', 'server/ops.js']],
|
||||
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
|
||||
@@ -33,6 +34,10 @@ const checks = [
|
||||
['root production audit', 'npm', ['audit', '--omit=dev']],
|
||||
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
|
||||
['extension build', 'npm', ['run', 'build:extension']],
|
||||
// Must run after the build: validates the exact artifact uploaded to AMO.
|
||||
// --warnings-as-errors is required: addons-linter exits 0 on warnings, and AMO
|
||||
// rejects on them, so without it this check would pass a rejectable build.
|
||||
['AMO validation (firefox)', 'npx', ['addons-linter', '--warnings-as-errors', 'dist/koalasync-firefox.zip']],
|
||||
['website build', 'node', ['website/build.cjs']]
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "2.5.4";
|
||||
export const APP_VERSION = "2.6.1";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
|
||||
+6
-1
@@ -599,6 +599,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
|
||||
// eslint-disable-next-line no-unsanitized/property -- both branches are hardcoded literals, no dynamic input
|
||||
desc.innerHTML = isDE
|
||||
? 'Verbunden! <br><span style="color:var(--accent); font-weight:bold;">Wähle jetzt einen Video-Tab in der Erweiterung aus.</span>'
|
||||
: 'Connected! <br><span style="color:var(--accent); font-weight:bold;">Now select a video tab in the extension.</span>';
|
||||
@@ -616,6 +617,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(updateCountdown, 1000);
|
||||
|
||||
const closeLabel = isDE ? 'TAB JETZT SCHLIESSEN' : 'CLOSE TAB NOW';
|
||||
// eslint-disable-next-line no-unsanitized/property -- closeLabel is a hardcoded literal, no dynamic input
|
||||
actions.innerHTML = `
|
||||
<div class="join-card-actions">
|
||||
<button class="btn btn-success" onclick="window.close()">${closeLabel}</button>
|
||||
@@ -634,6 +636,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
title.textContent = isDE ? 'Fehler' : 'Error';
|
||||
desc.textContent = isDE ? `Beitritt fehlgeschlagen: ${message}` : `Join failed: ${message}`;
|
||||
const retryLabel = isDE ? 'ERNEUT VERSUCHEN' : 'TRY AGAIN';
|
||||
// eslint-disable-next-line no-unsanitized/property -- retryLabel is a hardcoded literal, no dynamic input
|
||||
actions.innerHTML = `
|
||||
<div class="join-card-actions">
|
||||
<button class="btn btn-primary" onclick="location.reload()">${retryLabel}</button>
|
||||
@@ -1366,10 +1369,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
const originalHTML = copyBtn.innerHTML;
|
||||
|
||||
// eslint-disable-next-line no-unsanitized/property -- both branches are hardcoded literals, no dynamic input
|
||||
copyBtn.innerHTML = isDE ? '✅ Kopiert!' : '✅ Copied!';
|
||||
copyBtn.disabled = true;
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line no-unsanitized/property -- restores markup captured from this same button above
|
||||
copyBtn.innerHTML = originalHTML;
|
||||
copyBtn.disabled = false;
|
||||
}, 2000);
|
||||
|
||||
@@ -235,6 +235,7 @@ async function compile() {
|
||||
'styles/hero.css',
|
||||
'styles/landing-primary.css',
|
||||
'styles/legal.css',
|
||||
'styles/support.css',
|
||||
'styles/landing-controls.css',
|
||||
'styles/join-spinner.css',
|
||||
'styles/landing-sections.css',
|
||||
@@ -514,6 +515,7 @@ async function compile() {
|
||||
{ src: 'join.html', dest: 'join.html' },
|
||||
{ src: 'imprint.html', dest: 'imprint.html' },
|
||||
{ src: 'privacy.html', dest: 'privacy.html' },
|
||||
{ src: 'site-access-help.html', dest: 'site-access-help.html' },
|
||||
{ src: 'impressum-de.html', dest: 'de/impressum.html' },
|
||||
{ src: 'datenschutz-de.html', dest: 'de/datenschutz.html' },
|
||||
{ src: 'impressum.html', dest: 'impressum.html' },
|
||||
@@ -723,6 +725,12 @@ function generateSitemap(wwwDir) {
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/site-access-help.html</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/impressum</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Starte eine Watch Party auf Netflix, Emby, Jellyfin, YouTube, Twitch und fast jedem HTML5-Video. Open Source, datenschutzfreundlich und ohne Konto.",
|
||||
"NAV_FEATURES": "Funktionen",
|
||||
"NAV_HOW_IT_WORKS": "So funktioniert's",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync kann nicht auf deine Videoseite zugreifen?",
|
||||
"SUPPORT_BANNER_CTA": "Websitezugriff reparieren",
|
||||
"HERO_TITLE": "Mach jedes Video zur <span class='hero-highlight'>Watch Party</span>.",
|
||||
"HERO_SUBTITLE": "Dein Kino-Abend auf Distanz, ohne Lags. Kein Account, kein Tracking, keine Werbung. Einfach Link teilen und gemeinsam Play drücken.",
|
||||
"HERO_MASCOT_ALT": "Ein niedlicher Koala steht da und schaut nach unten auf die Download-Buttons",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Host a watch party on Netflix, Emby, Jellyfin, YouTube, Twitch and almost any HTML5 video. Open source, privacy-first, and account-free.",
|
||||
"NAV_FEATURES": "Features",
|
||||
"NAV_HOW_IT_WORKS": "How it works",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync cannot access your video site?",
|
||||
"SUPPORT_BANNER_CTA": "Fix website access",
|
||||
"HERO_TITLE": "Turn any video into a <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Your remote movie night without lags. No account, no tracking, no ads. Just share a link and press play together.",
|
||||
"HERO_MASCOT_ALT": "A cute koala standing and looking down at the download buttons",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Organiza una watch party en Netflix, Emby, Jellyfin, YouTube, Twitch y casi cualquier vídeo HTML5. Open source, privada y sin cuenta.",
|
||||
"NAV_FEATURES": "Características",
|
||||
"NAV_HOW_IT_WORKS": "Cómo funciona",
|
||||
"SUPPORT_BANNER_TEXT": "¿KoalaSync no puede acceder al sitio del vídeo?",
|
||||
"SUPPORT_BANNER_CTA": "Reparar acceso al sitio",
|
||||
"HERO_TITLE": "Convierte cualquier vídeo en una <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Tu noche de cine a distancia, sin retrasos. Sin cuenta, sin rastreo, sin anuncios. Comparte un enlace y dale al play juntos.",
|
||||
"HERO_MASCOT_ALT": "Un lindo koala de pie mirando hacia abajo a los botones de descarga",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Organisez une watch party sur Netflix, Emby, Jellyfin, YouTube, Twitch et presque toute vidéo HTML5. Open source, privé et sans compte.",
|
||||
"NAV_FEATURES": "Fonctionnalités",
|
||||
"NAV_HOW_IT_WORKS": "Comment ça marche",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync n’accède pas à votre site vidéo ?",
|
||||
"SUPPORT_BANNER_CTA": "Réparer l’accès au site",
|
||||
"HERO_TITLE": "Transformez n'importe quelle vidéo en <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Votre soirée ciné à distance, sans décalage. Pas de compte, pas de tracking, pas de pub. Partagez un lien et lancez la lecture ensemble.",
|
||||
"HERO_MASCOT_ALT": "Un koala mignon debout et regardant vers le bas les boutons de téléchargement",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Avvia una watch party su Netflix, Emby, Jellyfin, YouTube, Twitch e quasi ogni video HTML5. Open source, privata e senza account.",
|
||||
"NAV_FEATURES": "Funzionalità",
|
||||
"NAV_HOW_IT_WORKS": "Come Funziona",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync non riesce ad accedere al sito video?",
|
||||
"SUPPORT_BANNER_CTA": "Ripristina l’accesso al sito",
|
||||
"HERO_TITLE": "Trasforma qualsiasi video in una <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "La tua serata film a distanza, senza lag. Niente account, niente tracciamento, niente pubblicità. Condividi un link e premete play insieme.",
|
||||
"HERO_MASCOT_ALT": "Un simpatico koala che guarda i pulsanti di download",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Netflix、Emby、Jellyfin、YouTube、Twitch、ほぼすべてのHTML5動画でウォッチパーティーを開催。オープンソース、プライバシー重視、アカウント不要。",
|
||||
"NAV_FEATURES": "機能",
|
||||
"NAV_HOW_IT_WORKS": "仕組み",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync が動画サイトにアクセスできませんか?",
|
||||
"SUPPORT_BANNER_CTA": "サイトアクセスを修復",
|
||||
"HERO_TITLE": "どんな動画も<span class='hero-highlight'>ウォッチパーティー</span>に。",
|
||||
"HERO_SUBTITLE": "離れていても一緒に映画ナイト。アカウント不要、トラッキングなし、広告なし。リンクを共有して、一緒に再生するだけ。",
|
||||
"HERO_MASCOT_ALT": "ダウンロードボタンを見下ろしているかわいいコアラ",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch 및 거의 모든 HTML5 영상에서 워치 파티를 열어보세요. 오픈소스, 개인정보 보호 중심, 계정 불필요.",
|
||||
"NAV_FEATURES": "특징",
|
||||
"NAV_HOW_IT_WORKS": "작동 방식",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync가 동영상 사이트에 액세스할 수 없나요?",
|
||||
"SUPPORT_BANNER_CTA": "사이트 액세스 복구",
|
||||
"HERO_TITLE": "어떤 영상이든 <span class='hero-highlight'>워치 파티</span>로.",
|
||||
"HERO_SUBTITLE": "지연 없는 원격 영화의 밤. 계정 없음, 추적 없음, 광고 없음. 링크만 공유하고 함께 재생하세요.",
|
||||
"HERO_MASCOT_ALT": "다운로드 버튼을 내려다보고 서 있는 귀여운 코알라",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Start een watch party op Netflix, Emby, Jellyfin, YouTube, Twitch en bijna elke HTML5-video. Open source, privacyvriendelijk en zonder account.",
|
||||
"NAV_FEATURES": "Functies",
|
||||
"NAV_HOW_IT_WORKS": "Hoe het werkt",
|
||||
"SUPPORT_BANNER_TEXT": "Heeft KoalaSync geen toegang tot je videosite?",
|
||||
"SUPPORT_BANNER_CTA": "Websitetoegang herstellen",
|
||||
"HERO_TITLE": "Maak van elke video een <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Jouw filmavond op afstand, zonder vertraging. Geen account, geen tracking, geen advertenties. Deel een link en druk samen op play.",
|
||||
"HERO_MASCOT_ALT": "Een schattige koala die staat en naar beneden kijkt naar de downloadknoppen",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Zorganizuj watch party na Netflix, Emby, Jellyfin, YouTube, Twitch i prawie każdym wideo HTML5. Open source, prywatnie i bez konta.",
|
||||
"NAV_FEATURES": "Funkcje",
|
||||
"NAV_HOW_IT_WORKS": "Jak to działa",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync nie ma dostępu do strony z filmem?",
|
||||
"SUPPORT_BANNER_CTA": "Napraw dostęp do strony",
|
||||
"HERO_TITLE": "Zamień dowolne wideo w <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Twój zdalny wieczór filmowy bez opóźnień. Bez konta, bez śledzenia, bez reklam. Udostępnij link i naciśnijcie play razem.",
|
||||
"HERO_MASCOT_ALT": "Uroczy koala stojący i patrzący w dół na przyciski pobierania",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Crie uma watch party na Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5. Open source, privado e sem conta.",
|
||||
"NAV_FEATURES": "Recursos",
|
||||
"NAV_HOW_IT_WORKS": "Como Funciona",
|
||||
"SUPPORT_BANNER_TEXT": "O KoalaSync não consegue acessar o site do vídeo?",
|
||||
"SUPPORT_BANNER_CTA": "Corrigir acesso ao site",
|
||||
"HERO_TITLE": "Transforme qualquer vídeo em uma <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Sua sessão de cinema à distância, sem atrasos. Sem conta, sem rastreamento, sem anúncios. Compartilhe um link e deem play juntos.",
|
||||
"HERO_MASCOT_ALT": "Um coala fofo olhando para os botões de download",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Cria uma watch party na Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5. Open source, privado e sem conta.",
|
||||
"NAV_FEATURES": "Funcionalidades",
|
||||
"NAV_HOW_IT_WORKS": "Como Funciona",
|
||||
"SUPPORT_BANNER_TEXT": "O KoalaSync não consegue aceder ao site do vídeo?",
|
||||
"SUPPORT_BANNER_CTA": "Corrigir acesso ao site",
|
||||
"HERO_TITLE": "Transforma qualquer vídeo numa <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "A tua noite de cinema à distância, sem atrasos. Sem conta, sem rastreio, sem anúncios. Partilha um link e carreguem no play juntos.",
|
||||
"HERO_MASCOT_ALT": "Um coala fofo a olhar para os botões de download",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Запускайте watch party в Netflix, Emby, Jellyfin, YouTube, Twitch и почти любом HTML5-видео. Open source, приватно и без аккаунта.",
|
||||
"NAV_FEATURES": "Функции",
|
||||
"NAV_HOW_IT_WORKS": "Как это работает",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync не может получить доступ к сайту с видео?",
|
||||
"SUPPORT_BANNER_CTA": "Исправить доступ к сайту",
|
||||
"HERO_TITLE": "Преврати любое видео в <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Ваш киновечер на расстоянии, без задержек. Без аккаунта, без слежки, без рекламы. Просто поделитесь ссылкой и нажмите play вместе.",
|
||||
"HERO_MASCOT_ALT": "Милый коала стоит и смотрит вниз на кнопки загрузки",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch ve neredeyse tüm HTML5 videolarında watch party başlatın. Açık kaynak, gizlilik odaklı ve hesapsız.",
|
||||
"NAV_FEATURES": "Özellikler",
|
||||
"NAV_HOW_IT_WORKS": "Nasıl Çalışır",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync video sitesine erişemiyor mu?",
|
||||
"SUPPORT_BANNER_CTA": "Site erişimini düzelt",
|
||||
"HERO_TITLE": "Herhangi bir videoyu <span class='hero-highlight'>watch party</span>'ye dönüştür.",
|
||||
"HERO_SUBTITLE": "Gecikmesiz uzaktan film gecen. Hesap yok, takip yok, reklam yok. Sadece bir bağlantı paylaş ve birlikte oynatın.",
|
||||
"HERO_MASCOT_ALT": "İndirme düğmelerine bakan sevimli bir koala",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "Запускайте watch party у Netflix, Emby, Jellyfin, YouTube, Twitch і майже будь-якому HTML5-відео. Open source, приватно й без акаунта.",
|
||||
"NAV_FEATURES": "особливості",
|
||||
"NAV_HOW_IT_WORKS": "Як це працює",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync не має доступу до сайту з відео?",
|
||||
"SUPPORT_BANNER_CTA": "Виправити доступ до сайту",
|
||||
"HERO_TITLE": "Перетвори будь-яке відео на <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Ваш кіновечір на відстані, без затримок. Без акаунта, без стеження, без реклами. Просто поділіться посиланням і натисніть play разом.",
|
||||
"HERO_MASCOT_ALT": "Мила коала стоїть і дивиться вниз на кнопки завантаження",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"TWITTER_DESCRIPTION": "在 Netflix、Emby、Jellyfin、YouTube、Twitch 和几乎任意 HTML5 视频上发起一起看派对。开源、重视隐私、无需账号。",
|
||||
"NAV_FEATURES": "特征",
|
||||
"NAV_HOW_IT_WORKS": "它是如何运作的",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync 无法访问视频网站?",
|
||||
"SUPPORT_BANNER_CTA": "修复网站访问权限",
|
||||
"HERO_TITLE": "把任意视频变成<span class='hero-highlight'>一起看派对</span>。",
|
||||
"HERO_SUBTITLE": "远程电影之夜,毫无延迟。无需账号、无跟踪、无广告。分享链接,一起播放。",
|
||||
"HERO_MASCOT_ALT": "一只可爱的考拉站着,低头看着下载按钮",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="lang-en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KoalaSync Cannot Access Your Video Site? Fix Website Access</title>
|
||||
<meta name="description" content="Restore KoalaSync website access in Chrome, Edge, Brave, Opera, or Firefox when a video tab cannot be linked or controlled.">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="preload" href="style.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/site-access-help.html">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/site-access-help.html">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/site-access-help.html">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://sync.koalastuff.net/site-access-help.html">
|
||||
<meta property="og:title" content="Fix KoalaSync website access">
|
||||
<meta property="og:description" content="Quick browser-specific steps for reconnecting KoalaSync to a video tab.">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/og-image.png">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "TechArticle",
|
||||
"headline": "Fix KoalaSync website access",
|
||||
"description": "Restore KoalaSync access when a browser blocks the extension on a video website.",
|
||||
"inLanguage": "en",
|
||||
"url": "https://sync.koalastuff.net/site-access-help.html",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="lang-init.min.js"></script>
|
||||
</head>
|
||||
<body class="support-page">
|
||||
<div class="scroll-progress-bar"></div>
|
||||
<a class="skip-link" href="#main-content">Skip to troubleshooting</a>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a class="logo-area" href="./" aria-label="KoalaSync home">
|
||||
<img class="nav-logo-img" src="assets/NewLogoIcon_128.webp" srcset="assets/NewLogoIcon_64.webp 64w, assets/NewLogoIcon_128.webp 128w, assets/NewLogoIcon.webp 256w" sizes="64px" alt="KoalaSync logo" width="64" height="64">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="./">Home</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">GitHub</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light or dark theme" title="Toggle light or dark theme">
|
||||
<svg class="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
|
||||
<svg class="theme-icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
|
||||
</button>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main id="main-content" class="support-content">
|
||||
<header class="support-hero">
|
||||
<span class="support-status">Website access troubleshooting</span>
|
||||
<h1>Connected, but the video tab does nothing?</h1>
|
||||
<p class="support-lead">Your browser may be withholding KoalaSync’s access to that website. The room connection can still look healthy while KoalaSync cannot read or control the player.</p>
|
||||
</header>
|
||||
|
||||
<section class="support-card" aria-labelledby="quick-fix-title">
|
||||
<span class="support-eyebrow">Start here</span>
|
||||
<h2 id="quick-fix-title">Use the built-in one-click fix</h2>
|
||||
<ol class="support-steps">
|
||||
<li>Open the video website and then open the KoalaSync popup.</li>
|
||||
<li>Select the video tab. If access is blocked, KoalaSync shows an access notice instead of silently pretending the tab is connected.</li>
|
||||
<li>Select <strong>Allow access</strong> and approve the browser prompt. KoalaSync retries the tab automatically.</li>
|
||||
<li>If the prompt does not appear, use the manual browser steps below and reload the video page once.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div class="support-callout" role="note">
|
||||
<span class="support-callout-icon" aria-hidden="true">i</span>
|
||||
<div>
|
||||
<strong>This is not a new KoalaSync permission.</strong>
|
||||
<p>Modern browsers let users, profiles, and administrators withhold extension access per website. Chrome’s newer extension menu makes that existing control much more visible. KoalaSync now detects the blocked state and asks for access only after you select a video tab.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-grid">
|
||||
<section class="support-card" aria-labelledby="chromium-title">
|
||||
<span class="support-eyebrow">Chrome, Edge, Brave, Opera</span>
|
||||
<h2 id="chromium-title">Allow KoalaSync on the current site</h2>
|
||||
<ol>
|
||||
<li>Keep the affected video tab open.</li>
|
||||
<li>Open the browser’s extensions menu near the address bar.</li>
|
||||
<li>Under <strong>Access requested</strong>, select KoalaSync and allow access.</li>
|
||||
<li>If needed, open KoalaSync’s extension details and change site access to <strong>On this site</strong> or <strong>On all sites</strong>.</li>
|
||||
<li>Reload the video tab and select it again in KoalaSync.</li>
|
||||
</ol>
|
||||
<p><a class="support-link" href="https://support.google.com/chrome/answer/2664769?hl=en" target="_blank" rel="noopener">Google’s official site-access instructions</a></p>
|
||||
</section>
|
||||
|
||||
<section class="support-card" aria-labelledby="firefox-title">
|
||||
<span class="support-eyebrow">Firefox</span>
|
||||
<h2 id="firefox-title">Check extension permissions</h2>
|
||||
<ol>
|
||||
<li>Open <strong>Add-ons and themes</strong>, then select <strong>Extensions</strong>.</li>
|
||||
<li>Open KoalaSync and select <strong>Permissions</strong> or <strong>Permissions and data</strong>.</li>
|
||||
<li>Allow the requested website access.</li>
|
||||
<li>Return to the video site, reload it, and select the tab again.</li>
|
||||
</ol>
|
||||
<p><a class="support-link" href="https://support.mozilla.org/en-US/kb/manage-optional-permissions-extensions" target="_blank" rel="noopener">Mozilla’s official extension-permissions instructions</a></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="support-card" aria-labelledby="why-title">
|
||||
<span class="support-eyebrow">Why access is needed</span>
|
||||
<h2 id="why-title">KoalaSync must reach the player you selected</h2>
|
||||
<p>KoalaSync injects a local content script into the selected tab so it can find the HTML5 player, read its playback position, and apply play, pause, and seek actions. The video and audio stream never leave your browser. Only synchronization state is sent through the relay.</p>
|
||||
<p>Access is requested for the website origin shown in the popup. Browser-internal pages such as <strong>chrome://</strong>, <strong>edge://</strong>, and <strong>about:</strong> cannot be controlled. Local files may also require the separate <strong>Allow access to file URLs</strong> switch in the extension details.</p>
|
||||
</section>
|
||||
|
||||
<section class="support-card" aria-labelledby="still-title">
|
||||
<span class="support-eyebrow">Still not working?</span>
|
||||
<h2 id="still-title">Run these checks in order</h2>
|
||||
<ul>
|
||||
<li>Update KoalaSync in both browsers. Friends using different releases should update before testing again.</li>
|
||||
<li>Reload the video page after granting access. An already-open page may still have the old blocked state.</li>
|
||||
<li>Open KoalaSync and reselect the exact tab containing the player.</li>
|
||||
<li>Confirm the debug report says <strong>Video Found</strong>. A page without a standard player may need site-specific support.</li>
|
||||
<li>Temporarily test without another extension that also modifies the player.</li>
|
||||
<li>If it still fails, collect a debug report from each participant before leaving the room.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="support-actions">
|
||||
<a class="btn btn-primary" href="./">Back to KoalaSync</a>
|
||||
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener">Report a reproducible issue</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p class="footer-ram">Rooms are ephemeral and stored in RAM.</p>
|
||||
<p class="footer-copyright">© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<div class="footer-links">
|
||||
<a href="imprint.html">Legal notice</a>
|
||||
<a href="privacy.html">Privacy</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+69
@@ -92,6 +92,75 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.site-access-banner {
|
||||
position: relative;
|
||||
z-index: 900;
|
||||
margin-top: 96px;
|
||||
border-block: 1px solid color-mix(in oklch, var(--accent) 42%, transparent);
|
||||
background: color-mix(in oklch, var(--accent) 16%, var(--bg));
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.site-access-banner-inner {
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-access-banner-icon {
|
||||
flex: 0 0 auto;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-green);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.site-access-banner-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.18em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-access-banner-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-access-banner {
|
||||
margin-top: 76px;
|
||||
}
|
||||
|
||||
.site-access-banner-inner {
|
||||
min-height: 64px;
|
||||
padding-block: 0.65rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.site-access-banner-icon {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/* --- Site-access troubleshooting page --- */
|
||||
.support-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.support-page::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 15% 8%, color-mix(in oklch, var(--accent) 15%, transparent), transparent 32rem),
|
||||
radial-gradient(circle at 85% 30%, color-mix(in oklch, var(--accent-terracotta) 10%, transparent), transparent 30rem);
|
||||
}
|
||||
|
||||
.support-content {
|
||||
width: min(980px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 9rem 0 4rem;
|
||||
}
|
||||
|
||||
.support-hero {
|
||||
max-width: 760px;
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-eyebrow,
|
||||
.support-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid color-mix(in oklch, var(--accent) 35%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--accent) 12%, var(--card));
|
||||
color: var(--accent);
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.support-status::before {
|
||||
content: '';
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0.3rem color-mix(in oklch, var(--accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.support-hero h1 {
|
||||
margin: 1rem 0;
|
||||
color: var(--text);
|
||||
font-size: clamp(2.35rem, 6vw, 4.3rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.support-lead {
|
||||
color: var(--text-muted);
|
||||
font-size: clamp(1rem, 2vw, 1.18rem);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.support-card,
|
||||
.support-callout {
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 22px;
|
||||
background: var(--card-surface);
|
||||
box-shadow: 0 18px 55px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.support-card {
|
||||
padding: clamp(1.35rem, 4vw, 2.25rem);
|
||||
}
|
||||
|
||||
.support-card + .support-card,
|
||||
.support-grid + .support-card,
|
||||
.support-card + .support-grid,
|
||||
.support-callout + .support-card {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.support-card h2,
|
||||
.support-card h3 {
|
||||
color: var(--text);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.support-card h2 {
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: clamp(1.35rem, 3vw, 1.75rem);
|
||||
}
|
||||
|
||||
.support-card h3 {
|
||||
margin-bottom: 0.55rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.support-card p,
|
||||
.support-card li,
|
||||
.support-callout p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.support-card p + p,
|
||||
.support-card p + ul,
|
||||
.support-card p + ol,
|
||||
.support-card ul + p,
|
||||
.support-card ol + p {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.support-card ul,
|
||||
.support-card ol {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.support-card li + li {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.support-steps {
|
||||
list-style: none;
|
||||
padding: 0 !important;
|
||||
counter-reset: support-step;
|
||||
}
|
||||
|
||||
.support-steps li {
|
||||
position: relative;
|
||||
min-height: 2.4rem;
|
||||
padding-left: 3.2rem;
|
||||
counter-increment: support-step;
|
||||
}
|
||||
|
||||
.support-steps li::before {
|
||||
content: counter(support-step);
|
||||
position: absolute;
|
||||
top: -0.15rem;
|
||||
left: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-green);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.support-callout {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
margin: 1.25rem 0;
|
||||
padding: 1.25rem;
|
||||
border-color: color-mix(in oklch, var(--accent) 40%, transparent);
|
||||
background: color-mix(in oklch, var(--accent) 10%, var(--card-surface));
|
||||
}
|
||||
|
||||
.support-callout-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-green);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.support-callout strong {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.support-link {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
.support-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.support-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.support-actions .btn {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
html.theme-light .support-card,
|
||||
html.theme-light .support-callout {
|
||||
background-color: var(--card-surface);
|
||||
box-shadow: 0 18px 45px rgba(44, 62, 37, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.support-content {
|
||||
width: min(100% - 1rem, 980px);
|
||||
padding-top: 7.5rem;
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.support-card {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.support-callout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -116,7 +116,7 @@
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"description": "{{SCHEMA_APP_DESC}}",
|
||||
"softwareVersion": "2.5.4",
|
||||
"softwareVersion": "2.6.1",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||
@@ -210,7 +210,7 @@
|
||||
</style>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<body class="landing-with-support-banner">
|
||||
<div class="scroll-progress-bar"></div>
|
||||
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
@@ -360,6 +360,17 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<aside class="site-access-banner" aria-labelledby="site-access-banner-text">
|
||||
<div class="container site-access-banner-inner">
|
||||
<span class="site-access-banner-icon" aria-hidden="true">!</span>
|
||||
<span id="site-access-banner-text">{{SUPPORT_BANNER_TEXT}}</span>
|
||||
<a href="/site-access-help.html" class="site-access-banner-link">
|
||||
<span>{{SUPPORT_BANNER_CTA}}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main id="main-content">
|
||||
<header class="hero" id="top">
|
||||
<div class="container hero-grid">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.5.4",
|
||||
"date": "2026-07-08T14:25:32Z"
|
||||
"version": "2.6.1",
|
||||
"date": "2026-07-15T03:57:56Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user