Compare commits

...

10 Commits

Author SHA1 Message Date
Timo 8050748e61 fix(extension): harden chat and target tab lifecycle 2026-07-31 09:48:22 +02:00
Timo 0b6ae803c8 feat: complete docked chat interactions 2026-07-29 22:31:40 +02:00
KoalaDev 172073896f fix: prevent language selector text clipping 2026-07-28 07:46:07 +02:00
KoalaDev ffd629a522 fix: remove unsupported legacy redirects 2026-07-28 07:35:46 +02:00
KoalaDev 2c5e2a7cc3 feat: expand help center and harden website SEO 2026-07-28 07:29:41 +02:00
KoalaDev fc005c0c88 docs: add Help Center and Edge store badge 2026-07-28 04:04:33 +02:00
KoalaDev f97885cf8d docs: render Edge icon in store badges 2026-07-28 03:33:07 +02:00
KoalaDev daa513bac5 docs: add Microsoft Edge store links 2026-07-28 03:26:23 +02:00
GitHub Action b3f9693d34 chore(release): update versions to v3.0.1 [skip ci] 2026-07-26 03:56:23 +00:00
Timo 53befe8bad merge: Google Drive and chat fixes for v3.0.1 2026-07-26 05:54:57 +02:00
66 changed files with 2102 additions and 372 deletions
+7
View File
@@ -137,6 +137,12 @@ jobs:
npm ci
npm run build:extension
- name: Generate extension checksums
run: |
cd dist
sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS
cat SHA256SUMS
- name: Generate artifact attestation for extensions
uses: actions/attest@v4
with:
@@ -158,6 +164,7 @@ jobs:
files: |
dist/koalasync-chrome.zip
dist/koalasync-firefox.zip
dist/SHA256SUMS
name: Release ${{ github.ref_name }}
generate_release_notes: true
draft: false
+13 -4
View File
@@ -6,15 +6,16 @@
<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-v3.0.0-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.0.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>
<a href="https://microsoftedge.microsoft.com/addons/detail/koalasync/gefnlpeoagcjfbmmfjjildkmofekbibg"><img src="https://img.shields.io/badge/Edge-Download-0078D7?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgRnJlZSA3LjMuMSBieSBAZm9udGF3ZXNvbWUgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbSBMaWNlbnNlIC0gaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlIChJY29uczogQ0MgQlkgNC4wLCBGb250czogU0lMIE9GTCAxLjEsIENvZGU6IE1JVCBMaWNlbnNlKSBDb3B5cmlnaHQgMjAyNiBGb250aWNvbnMsIEluYy4gLS0%2BPHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyMC4xIDM3LjRjNDEtMjUuMiA4Ny42LTM4LjIgMTM0LjktMzcuNCAxNjggMCAyNTcgMTIzLjggMjU3IDIxOS41LS4xIDMyLjctMTMgNjMuOS0zNS45IDg3LjItMjIuOSAyMy4yLTU0IDM2LjUtODYuNyAzNy03NS4yIDAtOTEuNS0yMy4xLTkxLjUtMzIgMC0zLjggMS4yLTYuMiA0LjgtOS40bDEtMS4yIC40LTEuNmMxMC41LTExLjUgMTUuOS0yNi4yIDE1LjktNDEuNiAwLTc4LjctODIuMi0xNDIuNy0xODQtMTQyLjctMzcuNS0uMy03NC41IDguOS0xMDcuNSAyNi45IDI3LTU3LjUgODIuNy05Ny42IDkxLjMtMTAzLjggLjgtLjYgLjMtLjggLjMtLjh6bTE1LjYgMzE4LjFjLTEuNCAzMCA0LjYgNjAgMTYuNCA4Ny4yIDEzLjYgMjYuNCAzMi43IDUxIDU2LjUgNjkuMy01OS41LTExLjUtMTExLjUtNDMuOS0xNDkuNC04OS4zLTM4LjEtNDYuNC01OS4yLTEwNC4zLTU5LjItMTY0LjggMC01MS4yIDYyLjQtOTQuNCAxMzYtOTQuNCAzNi42LS42IDcyLjQgMTAuOSAxMDEuOCAzMi43bC0zLjYgMS4yYy01MS41IDE3LjYtOTguNSA5MC43LTk4LjUgMTU4LjF6TTQ2OS44IDQwMGwtLjcgLjFjLTExLjggMTguOC0yNS45IDM1LjEtNDIuMiA0OS41LTMwLjggMjgtNjguMSA0NS41LTEwOC44IDQ5LjktMTguNiAuMy0zNi44LTMuMi01My44LTExLjQtMjUuNi0xMC4zLTQ3LjEtMzAtNjEuNi01My0xNC40LTIzLjktMjEuMS01MS43LTE5LTc5LjYtLjYtMjAuMSA1LjQtNDAuMyAxNS01OC4yIDEzLjkgMzMuMSAzNy41IDYxLjMgNjcuNiA4MC44czY1LjUgMjkuNSAxMDEuNCAyOC42YzMxIC4zIDYyLjEtNi43IDkwLjItMjAuNWwxLjktLjljMy45LTIuMyA3LjctMy45IDExLjYgMCA0LjUgNC45IDEuOCA5LjItMS4yIDE0LS4yIC4yLS4zIC41LS40IC43eiIvPjwvc3ZnPg%3D%3D" alt="Microsoft Edge Add-on"></a>
</p>
<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 v3.0.0 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v3.0.1 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
@@ -46,9 +47,10 @@ The easiest and safest way to install KoalaSync is directly through the official
<p>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white&style=for-the-badge" alt="Chrome Extension"></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&style=for-the-badge" alt="Firefox Add-on"></a>
<a href="https://microsoftedge.microsoft.com/addons/detail/koalasync/gefnlpeoagcjfbmmfjjildkmofekbibg"><img src="https://img.shields.io/badge/Edge-Download-0078D7?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgRnJlZSA3LjMuMSBieSBAZm9udGF3ZXNvbWUgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbSBMaWNlbnNlIC0gaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlIChJY29uczogQ0MgQlkgNC4wLCBGb250czogU0lMIE9GTCAxLjEsIENvZGU6IE1JVCBMaWNlbnNlKSBDb3B5cmlnaHQgMjAyNiBGb250aWNvbnMsIEluYy4gLS0%2BPHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyMC4xIDM3LjRjNDEtMjUuMiA4Ny42LTM4LjIgMTM0LjktMzcuNCAxNjggMCAyNTcgMTIzLjggMjU3IDIxOS41LS4xIDMyLjctMTMgNjMuOS0zNS45IDg3LjItMjIuOSAyMy4yLTU0IDM2LjUtODYuNyAzNy03NS4yIDAtOTEuNS0yMy4xLTkxLjUtMzIgMC0zLjggMS4yLTYuMiA0LjgtOS40bDEtMS4yIC40LTEuNmMxMC41LTExLjUgMTUuOS0yNi4yIDE1LjktNDEuNiAwLTc4LjctODIuMi0xNDIuNy0xODQtMTQyLjctMzcuNS0uMy03NC41IDguOS0xMDcuNSAyNi45IDI3LTU3LjUgODIuNy05Ny42IDkxLjMtMTAzLjggLjgtLjYgLjMtLjggLjMtLjh6bTE1LjYgMzE4LjFjLTEuNCAzMCA0LjYgNjAgMTYuNCA4Ny4yIDEzLjYgMjYuNCAzMi43IDUxIDU2LjUgNjkuMy01OS41LTExLjUtMTExLjUtNDMuOS0xNDkuNC04OS4zLTM4LjEtNDYuNC01OS4yLTEwNC4zLTU5LjItMTY0LjggMC01MS4yIDYyLjQtOTQuNCAxMzYtOTQuNCAzNi42LS42IDcyLjQgMTAuOSAxMDEuOCAzMi43bC0zLjYgMS4yYy01MS41IDE3LjYtOTguNSA5MC43LTk4LjUgMTU4LjF6TTQ2OS44IDQwMGwtLjcgLjFjLTExLjggMTguOC0yNS45IDM1LjEtNDIuMiA0OS41LTMwLjggMjgtNjguMSA0NS41LTEwOC44IDQ5LjktMTguNiAuMy0zNi44LTMuMi01My44LTExLjQtMjUuNi0xMC4zLTQ3LjEtMzAtNjEuNi01My0xNC40LTIzLjktMjEuMS01MS43LTE5LTc5LjYtLjYtMjAuMSA1LjQtNDAuMyAxNS01OC4yIDEzLjkgMzMuMSAzNy41IDYxLjMgNjcuNiA4MC44czY1LjUgMjkuNSAxMDEuNCAyOC42YzMxIC4zIDYyLjEtNi43IDkwLjItMjAuNWwxLjktLjljMy45LTIuMyA3LjctMy45IDExLjYgMCA0LjUgNC45IDEuOCA5LjItMS4yIDE0LS4yIC4yLS4zIC41LS40IC43eiIvPjwvc3ZnPg%3D%3D&style=for-the-badge" alt="Microsoft Edge Add-on"></a>
</p>
*(For manual offline installation: Download the latest `.zip` from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page and load it as an "Unpacked Extension" in Developer Mode).*
*(For manual offline installation: Download the latest `.zip` from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page, extract it, and load the extracted browser package using your browser's developer or manual extension installation process.)*
**How to use:**
1. **Create a Room:** Click the Koala icon in your browser and hit `+ Create New Room`.
@@ -110,7 +112,14 @@ To verify your relay is reachable from outside, visit `https://your-domain.com`
#### Supply Chain Security (v2.2.2+)
All official release artifacts (Docker images and extension binaries) are published with signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) to prove they were built from this repository's source code.
Extension ZIPs attached to GitHub Releases and Docker images published to GitHub Container Registry receive signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations). These attestations connect an artifact's exact digest to this repository, its release tag and commit, and the public GitHub Actions workflow that built it.
Each new GitHub Release also includes `SHA256SUMS` for a quick checksum comparison:
```bash
sha256sum --check SHA256SUMS
```
Checksums detect a changed or incomplete download. Signed provenance additionally verifies where the artifact came from and which workflow built it. Neither check guarantees that the software contains no bugs or vulnerabilities. These attestations cover the GitHub release ZIPs and GHCR images; they do not by themselves prove that browser-store packages are byte-for-byte identical.
**Verify a Docker image:**
```bash
+15
View File
@@ -4,6 +4,21 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v3.0.2] — 2026-07-31
### Added
- **Extension: Quick reactions** — Adds six encrypted one-click reactions with a local choice between chat-only display and bounded falling reactions over the video.
- **Extension: Resilient chat activity** — Keeps a bounded per-room browser-session timeline for play, pause, seek, force-sync, join, and leave rows so overlay reinjection no longer loses them.
### Changed
- **Extension: Real dock behavior** — Anchors the collapsed Koala chat launcher to the selected side, reserves a page column in normal and fullscreen layouts, and clips viewport-bound page layers away from the open dock; detached mode remains freely movable.
- **Extension: Koala chat launcher** — Replaces the generic speech-bubble emoji with the KoalaSync extension icon plus a chat marker.
- **Extension: Reliable long chat sends** — Accepts up to 5000 Unicode code points, splits them into at most ten compatible 500-codepoint messages, waits for each relay echo before clearing text, and suppresses chat notifications while the selected video tab is focused.
### Fixed
- **Extension: Strict target-tab lifecycle** — Fully deactivates playback, audio, heartbeat, observer, seek-bridge, and chat code before changing targets, cleans up superseded injections, and limits website-bridge status delivery to the KoalaSync site instead of broadcasting to every open tab.
- **Extension: Tab switching and page restore** — Keeps ordinary tab changes idle and injection-free, makes selecting the same target idempotent, blocks delayed callbacks after teardown, and restores the existing target cleanly after Firefox back/forward-cache suspension.
## [v3.0.1] — 2026-07-26
### Added
+22 -2
View File
@@ -49,9 +49,13 @@ the stamped value as AAD.
## Client policy
- Maximum plaintext length: 500 Unicode code points.
- Maximum plaintext length per relay message: 500 Unicode code points.
- The composer accepts up to 5000 Unicode code points and sends them sequentially
as at most ten independently encrypted `chat-v1` messages.
- Decrypted text is untrusted. Escape HTML before applying the supported limited
Markdown formatting.
- Quick reactions use the same encrypted message path as text. They remain readable
by current chat clients without adding a relay-visible reaction event.
- No read receipts and no typing indicators.
- The local message DOM is bounded; this is presentation state, not server history.
@@ -61,15 +65,31 @@ the stamped value as AAD.
- Live modes: right, left, and detached.
- Detached mode is draggable and moderately resizable. Size and position are stored
per origin and clamped to the current viewport.
- The overlay uses a Shadow DOM and never changes host-page layout.
- Left and right modes anchor the launcher to the selected edge and reserve a
full-height page column while the panel is open. Detached mode is the only freely
movable launcher.
- Docking keeps a reserved column on narrow pages and in fullscreen instead of
silently falling back to an overlay.
- The overlay uses a Shadow DOM. Docking changes root-page or fullscreen-container
width and margins while the dock is open and clips viewport-bound page layers to
the remaining page column. The overrides stop applying when chat is detached or
closed, and the injected style element is removed when chat is destroyed.
- On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains
visible.
- Six encrypted quick reactions are available from the composer. Users can keep them
in chat or additionally show a bounded falling-emoji animation over the video.
- Playback and room activity is retained in a bounded per-room browser-session
timeline so overlay reinjection does not lose command rows. It is not relay history.
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations.
- Without a key, the panel stays closed and a disabled chat control explains that a
current invite link is required.
- Chat display is a local option and defaults to off. Enabling or disabling it never
deletes the room chat secret, so it can be enabled later without creating a room.
- Without the relay `chat` capability, no chat control is shown.
- System notifications are suppressed while the selected video tab and its browser
window are focused.
- A send succeeds only after the relay echoes the authenticated ciphertext back to
the sender. Unconfirmed and remaining split messages stay in the composer.
## Mixed-version rollout
+6 -5
View File
@@ -37,9 +37,10 @@
- **Category:** Social / Communication / Privacy
- **Status:** Planned. This is a roadmap target, not a release announcement.
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
The relay stores no chat history and mixed extension versions use capability-gated
delivery.
Koala launcher over the selected player opens a dockable, detachable, resizable
overlay. Quick reactions can stay in chat or fall over the video, and a bounded
browser-session activity timeline survives overlay reinjection. The relay stores no
chat history and mixed extension versions use capability-gated delivery.
#### Core experience
@@ -57,8 +58,8 @@
#### Messaging
- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing
indicators.
- Replies with quoted context, per-message reaction counters, a full emoji picker,
mentions, and typing indicators.
- Edit and delete own messages with clear local tombstones; no silent mutation.
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
duplicate suppression after reconnects.
+1 -1
View File
@@ -24,7 +24,7 @@ When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions relea
- `README.md` (updates badge and announcement banner)
- `website/sitemap.xml` (updates `lastmod` dates)
3. **Commits and pushes** these version updates back to the `main` branch automatically with the commit message `chore(release): update versions to vX.X.X [skip ci]`.
4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives.
4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives with a `SHA256SUMS` checksum file and signed provenance attestations.
5. **Builds the website** and uploads website artifacts.
6. **Builds and publishes** the Docker image for the relay server to the GitHub Container Registry (`ghcr.io`).
-1
View File
@@ -96,4 +96,3 @@ syncserver.koalastuff.net {
encode zstd gzip
reverse_proxy KoalaSync:3000
}
+166 -62
View File
@@ -6,7 +6,8 @@ import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, norm
import { initTabManager } from './modules/tab-manager.js';
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js';
import { createChatEchoTracker, createChatSendLimiter, createLatestTaskQueue, normalizeRoomId, shouldShowChatNotification } from './chat-session.js';
import { createChatActivityStore } from './chat-activity.js';
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
import './page-api-seek-overrides.js';
@@ -89,6 +90,8 @@ let chatSecretGuard = '';
let chatSessionGeneration = 0;
let chatReceiveQueue = Promise.resolve();
const chatSendLimiter = createChatSendLimiter();
const chatEchoTracker = createChatEchoTracker();
const chatActivityStore = createChatActivityStore();
const webJoinCoordinator = createLatestTaskQueue();
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
function serverSupportsChat() {
@@ -100,9 +103,15 @@ function invalidateChatSession() {
chatSessionGeneration++;
chatReceiveQueue = Promise.resolve();
chatSendLimiter.reset();
chatEchoTracker.reset();
clearChatKeyCache();
}
function clearChatActivity() {
chatActivityStore.clear();
if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: [] }).catch(() => {});
}
async function clearFailedJoinCredentials() {
webJoinCoordinator.invalidate();
connectIntent = false;
@@ -194,7 +203,7 @@ function ensureState() {
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
'hcmDesynced'
'hcmDesynced', 'chatActivityTimeline'
], (data) => {
clearTimeout(storageTimeout);
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
@@ -215,6 +224,7 @@ function ensureState() {
hostPeerId = currentRoom.hostPeerId || null;
controllers = Array.isArray(currentRoom.controllers) ? currentRoom.controllers : [];
serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : [];
chatActivityStore.restore(data.chatActivityTimeline);
}
if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced;
// L-2: enforce the desync invariant on restore — a persisted hcmDesynced=true
@@ -454,7 +464,7 @@ function emitEpisodeLobbyForCurrentPrivacy() {
// otherwise be redistributed across devices and resurrected on reinstall).
const LEGACY_SYNC_KEYS = [
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey',
'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username',
'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username',
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
@@ -581,7 +591,7 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
clearPendingTarget().catch(() => {});
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {});
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
@@ -608,6 +618,7 @@ async function leaveRoomAfterIdleGrace(reason) {
emit(EVENTS.LEAVE_ROOM, { peerId });
forceDisconnect();
currentRoom = null;
clearChatActivity();
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
controllers = [];
@@ -616,7 +627,7 @@ async function leaveRoomAfterIdleGrace(reason) {
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) await deactivateTargetTab(currentTabId);
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
@@ -626,6 +637,7 @@ async function leaveRoomAfterIdleGrace(reason) {
await clearPendingTarget();
await chrome.storage.session.set({
currentRoom: null,
chatActivityTimeline: [],
currentTabId: null,
currentTabTitle: null,
roomIdleSince: null,
@@ -863,6 +875,21 @@ function broadcastConnectionStatus(status) {
updateBadgeStatus();
}
async function broadcastJoinStatus(message, shouldSend = () => true) {
let websiteTabs = [];
try {
websiteTabs = await chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' });
} catch (_) {
// The website bridge is optional and may be unavailable.
}
if (!shouldSend()) return false;
chrome.runtime.sendMessage(message).catch(() => {});
await Promise.all(websiteTabs.map(tab =>
chrome.tabs.sendMessage(tab.id, message).catch(() => {})
));
return true;
}
function updateBadgeStatus() {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0;
@@ -924,9 +951,42 @@ function showNotification(senderName, action) {
});
}
function getTabForNotification(tabId) {
return new Promise(resolve => {
try {
chrome.tabs.get(tabId, tab => {
if (chrome.runtime.lastError) resolve(null);
else resolve(tab || null);
});
} catch (_) {
resolve(null);
}
});
}
function getWindowForNotification(windowId) {
return new Promise(resolve => {
try {
chrome.windows.get(windowId, windowInfo => {
if (chrome.runtime.lastError) resolve(null);
else resolve(windowInfo || null);
});
} catch (_) {
resolve(null);
}
});
}
function showChatNotification(displayName, text) {
chrome.storage.local.get(['chatNotifications', 'locale'], async (settings) => {
if (settings.chatNotifications === false) return;
const enabled = settings.chatNotifications !== false;
if (!enabled) return;
const targetTabId = normalizeTabId(currentTabId);
const tab = targetTabId === null ? null : await getTabForNotification(targetTabId);
const windowInfo = Number.isInteger(tab?.windowId)
? await getWindowForNotification(tab.windowId)
: null;
if (!shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo })) return;
await loadLocale(settings.locale || getSystemLanguage());
chrome.notifications.create(`chat_${Date.now()}`, {
@@ -948,17 +1008,21 @@ function chatActivityDisplayName(senderId) {
}
function sendChatActivity(action, senderId, timestamp = Date.now()) {
if (!currentTabId || !serverSupportsChat()) return;
if (!currentRoom || !serverSupportsChat()) return;
if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return;
const entry = chatActivityStore.add({
action,
senderId,
username: chatActivityDisplayName(senderId),
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now()
});
if (!entry) return;
if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: chatActivityStore.snapshot() }).catch(() => {});
if (!currentTabId) return;
chrome.tabs.sendMessage(Number(currentTabId), {
type: 'CHAT_EVENT',
event: {
action,
senderId,
username: chatActivityDisplayName(senderId),
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now()
}
}).catch(() => {});
event: entry
}).catch(error => addLog(`Chat activity delivery failed: ${error.message}`, 'warn'));
}
function scheduleReconnect() {
@@ -1156,7 +1220,10 @@ async function handleServerEvent(event, data) {
}
switch (event) {
case EVENTS.ROOM_DATA:
if (currentRoom?.roomId !== data.roomId) invalidateChatSession();
if (currentRoom?.roomId !== data.roomId) {
invalidateChatSession();
clearChatActivity();
}
currentRoom = data;
// Host Control Mode: adopt room role/mode on (re)join.
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
@@ -1216,12 +1283,7 @@ async function handleServerEvent(event, data) {
// Inform Website Bridge & Popup
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
chrome.runtime.sendMessage(joinStatusMsg).catch(() => {});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => {
chrome.tabs.sendMessage(tab.id, joinStatusMsg).catch(() => {});
});
});
await broadcastJoinStatus(joinStatusMsg);
break;
case EVENTS.CONTROL_MODE:
// Host Control Mode changed (toggle or host-leave fallback).
@@ -1262,6 +1324,7 @@ async function handleServerEvent(event, data) {
secret: chatKey
});
if (!isCurrentSession() || chatSecretGuard !== chatKey) return;
if (received.senderId === peerId) chatEchoTracker.acknowledge(received.ciphertext);
const senderPeer = currentRoom.peers?.find(candidate =>
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
);
@@ -1316,12 +1379,7 @@ async function handleServerEvent(event, data) {
});
// Inform Website Bridge & Popup
const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message };
chrome.runtime.sendMessage(errStatusMsg).catch(() => {});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => {
chrome.tabs.sendMessage(tab.id, errStatusMsg).catch(() => {});
});
});
await broadcastJoinStatus(errStatusMsg);
break;
case EVENTS.PLAY:
case EVENTS.PAUSE:
@@ -1879,8 +1937,12 @@ function shouldUsePageApiSeek(url) {
}
function installPageApiSeekBridge() {
if (window.__koalaPageApiSeekBridgeInstalled) return;
window.__koalaPageApiSeekBridgeInstalled = true;
if (window.__koalaPageApiSeekBridge?.activate) {
window.__koalaPageApiSeekBridge.activate();
return;
}
let active = true;
function currentMatch() {
return typeof window.koalaFindPageApiSeekProvider === 'function'
@@ -1916,17 +1978,23 @@ function installPageApiSeekBridge() {
}
}
window.addEventListener('message', (event) => {
function handleBridgeMessage(event) {
if (event.source !== window) return;
const data = event.data;
if (!data || data.__koalaPageApiSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return;
if (!data || data.__koalaPageApiSeek !== 1) return;
if (data.kind === 'destroy') {
destroy();
return;
}
if (!active || data.kind !== 'seek' || typeof data.time !== 'number') return;
seekWithPageApi(data.time);
});
}
// Disney+'s <video> currentTime is blob-relative and its scrubber lags, so
// the isolated-world content script can't read an accurate position. Push
// the real playhead/duration (seconds) from the page's media player.
setInterval(() => {
const timelineInterval = setInterval(() => {
if (!active) return;
try {
const match = currentMatch();
if (!match || match.provider !== 'disney') return;
@@ -1944,12 +2012,36 @@ function installPageApiSeekBridge() {
// Ignore transient errors (player teardown / element swap).
}
}, 250);
function destroy() {
if (!active) return;
active = false;
clearInterval(timelineInterval);
window.removeEventListener('message', handleBridgeMessage);
delete window.__koalaPageApiSeekBridge;
}
window.addEventListener('message', handleBridgeMessage);
window.__koalaPageApiSeekBridge = {
activate() {
active = true;
},
destroy
};
}
function setPageApiSeekEnabled(enabled) {
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true;
}
async function deactivateTargetTab(tabId) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) return;
resetAudioProcessingInTab(normalizedTabId);
await chrome.tabs.sendMessage(normalizedTabId, { type: 'TARGET_DEACTIVATE' }).catch(() => {});
await chrome.tabs.sendMessage(normalizedTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
}
function createHostAccessRequiredError(access, requestAdded, cause) {
const error = new Error(`Host access required for ${access.host || 'this website'}`);
error.code = HOST_ACCESS_REQUIRED_STATUS;
@@ -2209,10 +2301,19 @@ async function activateTargetTab(tabId, tabTitle, {
const previousTabId = normalizeTabId(currentTabId);
try {
if (previousTabId && previousTabId !== selectedTabId) {
await deactivateTargetTab(previousTabId);
}
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
try {
await injectContentScript(selectedTabId, { requestHostAccess });
} catch (error) {
if (activationGeneration !== targetActivationGeneration) {
if (normalizeTabId(currentTabId) !== selectedTabId) {
await deactivateTargetTab(selectedTabId);
}
if (error?.code === HOST_ACCESS_REQUIRED_STATUS
&& error.requestAdded === true
&& activeTargetActivation?.tabId !== selectedTabId) {
@@ -2229,8 +2330,7 @@ async function activateTargetTab(tabId, tabTitle, {
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
await deactivateTargetTab(previousTabId);
}
await chrome.storage.session.set({
currentTabId: null,
@@ -2268,33 +2368,29 @@ async function activateTargetTab(tabId, tabTitle, {
}
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId);
return { status: 'superseded' };
}
await applyAudioSettingsToTab(selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId);
return { status: 'superseded' };
}
await removeTabHostAccessRequest(chrome, selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId);
return { status: 'superseded' };
}
await clearPendingTarget();
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId);
return { status: 'superseded' };
}
currentTabId = selectedTabId;
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId && previousTabId !== selectedTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
}
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
@@ -2556,6 +2652,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
forceDisconnect();
currentRoom = null;
clearChatActivity();
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
controllers = [];
@@ -2633,10 +2730,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
await broadcastJoinStatus({ type: 'JOIN_STATUS', success: true, message: 'Already in room' });
if (typeof sendResponse === 'function') sendResponse({ status: 'ok' });
return;
}
@@ -2735,6 +2829,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
eventNotifications: localeData.browserNotifications === true,
peerId,
roomId: currentRoom.roomId,
activity: chatActivityStore.snapshot(),
strings: {
title: translated('CHAT_TITLE'),
liveOnly: translated('CHAT_LIVE_ONLY'),
@@ -2757,7 +2852,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
eventForceExecute: translated('NOTIF_FORCE_EXECUTE'),
eventAction: translated('TOAST_PEER_ACTION'),
eventJoined: translated('TOAST_PEER_JOINED'),
eventLeft: translated('TOAST_PEER_LEFT')
eventLeft: translated('TOAST_PEER_LEFT'),
quickReactions: translated('CHAT_QUICK_REACTIONS')
}
});
} else if (message.type === 'CHAT_SEND') {
@@ -2807,8 +2903,19 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'session_changed' });
return;
}
const echoPromise = chatEchoTracker.waitFor(ciphertext);
const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext));
sendResponse({ status: sent ? 'ok' : 'disconnected' });
if (!sent) {
chatEchoTracker.cancel(ciphertext);
sendResponse({ status: 'disconnected' });
return;
}
const acknowledged = await echoPromise;
if (!isCurrentSession() || chatSecretGuard !== chatKey) {
sendResponse({ status: 'session_changed' });
return;
}
sendResponse({ status: acknowledged ? 'ok' : 'unconfirmed' });
} catch (err) {
sendResponse({ status: err instanceof RangeError ? 'too_long' : 'invalid_message' });
}
@@ -2897,9 +3004,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
resetAudioProcessingInTab(currentTabId);
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
clearChatActivity();
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
controllers = [];
@@ -2908,7 +3015,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();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) await deactivateTargetTab(currentTabId);
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
@@ -2928,6 +3035,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
chrome.storage.session.set({
currentRoom: null,
chatActivityTimeline: [],
currentTabId: null,
currentTabTitle: null,
roomIdleSince: null,
@@ -2962,10 +3070,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const chatKey = validateChatSecret(rawChatKey);
if (!roomId) {
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
chrome.runtime.sendMessage(errMsg).catch(() => {});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => chrome.tabs.sendMessage(tab.id, errMsg).catch(() => {}));
});
await broadcastJoinStatus(errMsg);
sendResponse({ status: 'invalid_room_id' });
return;
}
@@ -2999,14 +3104,14 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
if (!isCurrentJoin()) {
const statusSent = await broadcastJoinStatus(
{ type: 'JOIN_STATUS', success: true, message: 'Already in room' },
isCurrentJoin
);
if (!statusSent || !isCurrentJoin()) {
sendResponse({ status: 'superseded' });
return { status: 'superseded' };
}
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
sendResponse({ status: 'already_joined' });
return { status: 'already_joined' };
}
@@ -3360,8 +3465,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(Number(previousTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
await deactivateTargetTab(previousTabId);
}
await clearPendingTarget();
await chrome.storage.session.set({
+57
View File
@@ -0,0 +1,57 @@
const ALLOWED_ACTIONS = new Set([
'play',
'pause',
'seek',
'force_sync_prepare',
'force_sync_execute',
'joined',
'left'
]);
export function createChatActivityStore(limit = 100) {
const maxEntries = Number.isInteger(limit) && limit > 0 ? limit : 100;
let entries = [];
function normalize(value) {
if (!value || !ALLOWED_ACTIONS.has(value.action) || typeof value.senderId !== 'string' || !value.senderId) {
return null;
}
const timestamp = Number(value.timestamp);
if (!Number.isFinite(timestamp)) return null;
const id = typeof value.id === 'string' && value.id
? value.id
: `${value.action}:${value.senderId}:${timestamp}`;
return {
id,
action: value.action,
senderId: value.senderId,
username: typeof value.username === 'string' ? value.username : '',
timestamp
};
}
return {
add(value) {
const entry = normalize(value);
if (!entry || entries.some(candidate => candidate.id === entry.id)) return null;
entries.push(entry);
if (entries.length > maxEntries) entries = entries.slice(-maxEntries);
return { ...entry };
},
clear() {
entries = [];
},
restore(values) {
entries = [];
for (const value of Array.isArray(values) ? values : []) {
const entry = normalize(value);
if (!entry || entries.some(candidate => candidate.id === entry.id)) continue;
entries.push(entry);
}
entries = entries.slice(-maxEntries);
},
snapshot() {
return entries.map(entry => ({ ...entry }));
}
};
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { createChatActivityStore } from './chat-activity.js';
describe('chat activity store', () => {
it('deduplicates commands and retains only the newest bounded entries', () => {
const store = createChatActivityStore(2);
expect(store.add({ action: 'play', senderId: 'a', timestamp: 1 })).toMatchObject({ id: 'play:a:1' });
expect(store.add({ action: 'play', senderId: 'a', timestamp: 1 })).toBeNull();
store.add({ action: 'pause', senderId: 'b', username: 'Bear', timestamp: 2 });
store.add({ action: 'seek', senderId: 'a', timestamp: 3 });
expect(store.snapshot()).toEqual([
{ id: 'pause:b:2', action: 'pause', senderId: 'b', username: 'Bear', timestamp: 2 },
{ id: 'seek:a:3', action: 'seek', senderId: 'a', username: '', timestamp: 3 }
]);
});
it('restores only valid activity without sharing mutable references', () => {
const store = createChatActivityStore();
store.restore([
{ id: 'joined:b:4', action: 'joined', senderId: 'b', username: 'Koala', timestamp: 4 },
{ action: 'unknown', senderId: 'b', timestamp: 5 },
{ action: 'left', senderId: '', timestamp: 6 }
]);
const snapshot = store.snapshot();
snapshot[0].username = 'Changed';
expect(store.snapshot()).toEqual([
{ id: 'joined:b:4', action: 'joined', senderId: 'b', username: 'Koala', timestamp: 4 }
]);
});
});
+37 -1
View File
@@ -29,5 +29,41 @@
return tokens;
}
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText });
function splitChatText(value, maxCodePoints = 500, maxChunks = 10) {
if (!Number.isInteger(maxCodePoints) || maxCodePoints < 1 || !Number.isInteger(maxChunks) || maxChunks < 1) {
throw new TypeError('Chat chunk limits must be positive integers');
}
let remaining = [...String(value ?? '').trim()];
if (remaining.length === 0) return [];
if (remaining.length > maxCodePoints * maxChunks) {
throw new RangeError('Chat composer text exceeds the chunk limit');
}
const chunks = [];
while (remaining.length > 0) {
if (remaining.length <= maxCodePoints) {
chunks.push(remaining.join('').trim());
break;
}
const remainingSlots = maxChunks - chunks.length;
const minimumSplit = Math.max(1, remaining.length - (remainingSlots - 1) * maxCodePoints);
let splitAt = maxCodePoints;
for (let index = maxCodePoints - 1; index >= minimumSplit; index--) {
if (/\s/u.test(remaining[index])) {
splitAt = index;
break;
}
}
const chunk = remaining.slice(0, splitAt).join('').trim();
remaining = remaining.slice(splitAt);
while (remaining.length > 0 && /\s/u.test(remaining[0])) remaining.shift();
if (chunk) chunks.push(chunk);
}
return chunks;
}
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText, splitChatText });
})(globalThis);
+21
View File
@@ -19,4 +19,25 @@ describe('chat formatting', () => {
{ type: 'em', text: 'italic' }
]);
});
it('splits up to 5000 Unicode code points into at most ten ordered chat-v1 messages', () => {
const split = globalThis.KoalaSyncChatFormat.splitChatText;
expect(split('hello world', 500, 10)).toEqual(['hello world']);
const emojiChunks = split('😀'.repeat(1001), 500, 10);
expect(emojiChunks.map(chunk => [...chunk].length)).toEqual([500, 500, 1]);
const maximumChunks = split('x'.repeat(5000), 500, 10);
expect(maximumChunks).toHaveLength(10);
expect(maximumChunks.every(chunk => [...chunk].length === 500)).toBe(true);
expect(maximumChunks.join('')).toBe('x'.repeat(5000));
expect(() => split('x'.repeat(5001), 500, 10)).toThrow(RangeError);
});
it('prefers whitespace boundaries without creating more chunks than the limit allows', () => {
const split = globalThis.KoalaSyncChatFormat.splitChatText;
const chunks = split(`${'a'.repeat(420)} ${'b'.repeat(179)}`, 500, 10);
expect(chunks).toEqual(['a'.repeat(420), 'b'.repeat(179)]);
});
});
+71 -8
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
@@ -7,6 +8,8 @@ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const manifestSource = fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8');
const buildSource = fs.readFileSync(path.join(extensionDir, '..', 'scripts', 'build-extension.cjs'), 'utf8');
const localeDir = path.join(extensionDir, 'locales');
const chatKeys = [
'LABEL_CHAT_ENABLED',
@@ -23,10 +26,24 @@ const chatKeys = [
'CHAT_MISSING_KEY',
'CHAT_TOO_LONG',
'CHAT_SEND_FAILED',
'CHAT_EMPTY'
'CHAT_EMPTY',
'LABEL_CHAT_REACTION_DISPLAY',
'LABEL_CHAT_REACTION_DISPLAY_TOOLTIP',
'OPTION_CHAT_REACTIONS_CHAT',
'OPTION_CHAT_REACTIONS_VIDEO',
'CHAT_QUICK_REACTIONS'
];
describe('chat overlay contract', () => {
it('skips SVG and other non-HTML documents before creating the overlay host', () => {
const documentGuard = "document.documentElement?.namespaceURI !== 'http://www.w3.org/1999/xhtml'";
expect(overlaySource).toContain(documentGuard);
expect(overlaySource.indexOf(documentGuard)).toBeLessThan(overlaySource.indexOf("document.createElement('div')"));
expect(() => vm.runInNewContext(overlaySource, {
document: { documentElement: { namespaceURI: 'http://www.w3.org/2000/svg' } }
})).not.toThrow();
});
it('isolates the overlay and never renders markup as HTML', () => {
expect(overlaySource).toContain("attachShadow({ mode: 'open' })");
expect(overlaySource).not.toMatch(/\.innerHTML\s*=|insertAdjacentHTML|\.outerHTML\s*=/);
@@ -60,20 +77,29 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain('window.removeEventListener(eventName, stopPageKeyboardShortcut, true)');
});
it('keeps the launcher draggable independently from dock mode', () => {
it('keeps the launcher draggable only in detached mode and anchors dock launchers', () => {
expect(overlaySource).toContain("launcher.addEventListener('pointerdown'");
expect(overlaySource).toMatch(/launcher\.addEventListener\('pointerdown'[\s\S]*layout\.mode !== 'detached'/);
expect(overlaySource).toContain("launcher.addEventListener('pointermove'");
expect(overlaySource).toContain('layout.launcherX = launcherDrag.x + deltaX');
expect(overlaySource).toContain('layout.launcherY = launcherDrag.y + deltaY');
expect(overlaySource).toContain('suppressLauncherClick = launcherDrag.moved');
expect(overlaySource).not.toMatch(/launcher\.style\.left = `\$\{layout\.x\}px`/);
expect(overlaySource).toContain("launcher.classList.toggle('docked-left'");
expect(overlaySource).toContain("launcher.classList.toggle('docked-right'");
});
it('reserves page space for real left and right dock modes', () => {
expect(overlaySource).toContain("const PAGE_DOCK_ATTRIBUTE = 'data-koalasync-chat-dock'");
expect(overlaySource).toContain('padding-left: var(${PAGE_DOCK_WIDTH}) !important');
expect(overlaySource).toContain('padding-right: var(${PAGE_DOCK_WIDTH}) !important');
expect(overlaySource).toContain('document.documentElement.setAttribute(PAGE_DOCK_ATTRIBUTE, side)');
expect(overlaySource).toContain('width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important');
expect(overlaySource).toContain('margin-left: var(${PAGE_DOCK_WIDTH}) !important');
expect(overlaySource).toContain('margin-right: var(${PAGE_DOCK_WIDTH}) !important');
expect(overlaySource).toContain('html[${PAGE_DOCK_ATTRIBUTE}] > body');
expect(overlaySource).toContain('clip-path: inset(0) !important');
expect(overlaySource).toContain('contain: layout paint !important');
expect(overlaySource).toContain('> :not(#koalasync-chat-overlay-host)');
expect(overlaySource).toContain('const target = document.fullscreenElement || document.documentElement');
expect(overlaySource).toContain('pageDockTarget = target');
expect(overlaySource).not.toContain('DOCK_MIN_PAGE_WIDTH');
expect(overlaySource).toContain('applyPageDock(layout.mode, dockWidth)');
expect(overlaySource).toContain('clearPageDock()');
});
@@ -85,7 +111,9 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain("unreadBadge.classList.toggle('visible', unreadCount > 0)");
expect(backgroundSource).toMatch(/received\.senderId !== peerId[\s\S]*showChatNotification\([\s\S]*senderPeer\.username[\s\S]*received\.senderId/);
expect(backgroundSource).toContain("chrome.notifications.create(`chat_${Date.now()}`");
expect(backgroundSource).toContain("if (settings.chatNotifications === false) return");
expect(backgroundSource).toContain('getTabForNotification(targetTabId)');
expect(backgroundSource).toContain('getWindowForNotification(tab.windowId)');
expect(backgroundSource).toContain('shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo })');
});
it('renders timestamped room activity and keeps activity notifications opt-in', () => {
@@ -98,16 +126,51 @@ describe('chat overlay contract', () => {
expect(backgroundSource).toContain("sendChatActivity(event, data.senderId, data.actionTimestamp)");
expect(backgroundSource).toContain("sendChatActivity('joined', data.peerId, Date.now())");
expect(backgroundSource).toContain("sendChatActivity('left', data.peerId, Date.now())");
expect(backgroundSource).toContain('activity: chatActivityStore.snapshot()');
expect(overlaySource).toContain('renderedActivityIds.has(event.id)');
expect(overlaySource).toContain('const MAX_RENDERED_ACTIVITY_IDS = 200');
expect(overlaySource).toContain('while (renderedActivityIdOrder.length > MAX_RENDERED_ACTIVITY_IDS)');
expect(overlaySource).toContain('renderedActivityIds.delete(renderedActivityIdOrder.shift())');
});
it('uses the KoalaSync icon and offers bounded encrypted quick reactions', () => {
expect(overlaySource).toContain("const LAUNCHER_ICON_DATA_URL = 'data:image/png;base64,");
expect(overlaySource).toContain('launcherIcon.src = LAUNCHER_ICON_DATA_URL');
expect(manifestSource).not.toContain('web_accessible_resources');
expect(overlaySource).toContain("const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥'])");
expect(overlaySource).toContain("messageRuntime({ type: 'CHAT_SEND', text: chunk })");
expect(overlaySource).toContain("chatReactionDisplay !== 'video'");
expect(overlaySource).toContain('MAX_REACTION_PARTICLES - reactionLayer.childElementCount');
expect(overlaySource).toContain('reducedMotion.matches');
});
it('splits a 5000-codepoint composer value into at most ten acknowledged chat-v1 messages', () => {
expect(overlaySource).toContain('const MAX_MESSAGE_CODE_POINTS = 500');
expect(overlaySource).toContain('const MAX_CHAT_CHUNKS = 10');
expect(overlaySource).toContain('MAX_MESSAGE_CODE_POINTS * MAX_CHAT_CHUNKS');
expect(overlaySource).toContain('KoalaSyncChatFormat?.splitChatText(');
expect(overlaySource).toMatch(/for \(const chunk of chunks\)[\s\S]*messageRuntime\(\{ type: 'CHAT_SEND', text: chunk \}\)/);
expect(overlaySource).toContain("textarea.value = chunks.slice(completedChunks).join('\\n')");
expect(backgroundSource).toContain('const echoPromise = chatEchoTracker.waitFor(ciphertext)');
expect(backgroundSource).toContain('chatEchoTracker.acknowledge(received.ciphertext)');
expect(backgroundSource).toContain("status: acknowledged ? 'ok' : 'unconfirmed'");
});
it('excludes test-only modules from production extension artifacts', () => {
expect(buildSource).toContain("/\\.test\\.[cm]?js$/u.test(item)");
});
it('guards async refresh/send work and clears all composer state on room reset', () => {
expect(overlaySource).toContain('generation !== refreshGeneration');
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
expect(overlaySource).toContain('textarea.value === submittedValue');
expect(overlaySource).toContain("clearComposerValue && textarea.value.trim() === text");
expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/);
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
expect(backgroundSource).toContain("status: 'rate_limited'");
expect(overlaySource).toContain('function setLocalStorage(values)');
expect(overlaySource).toMatch(/chrome\.storage\.local\.get\([\s\S]*data => \{\s*if \(destroyed\) return;/);
expect(overlaySource).toContain("if (destroyed || area !== 'local') return");
});
it('keeps chat hidden by default without discarding the room chat key', () => {
+240 -50
View File
@@ -1,4 +1,6 @@
(function initKoalaSyncChatOverlay() {
if (document.documentElement?.namespaceURI !== 'http://www.w3.org/1999/xhtml') return;
if (window.koalaSyncChatOverlay?.refresh) {
window.koalaSyncChatOverlay.refresh();
return;
@@ -11,9 +13,15 @@
const DEFAULT_HEIGHT = 520;
const LAUNCHER_SIZE = 48;
const EDGE_INSET = 8;
const DOCK_MIN_PAGE_WIDTH = 480;
const MAX_MESSAGE_CODE_POINTS = 500;
const MAX_CHAT_CHUNKS = 10;
const MAX_COMPOSER_CODE_POINTS = MAX_MESSAGE_CODE_POINTS * MAX_CHAT_CHUNKS;
const PAGE_DOCK_ATTRIBUTE = 'data-koalasync-chat-dock';
const PAGE_DOCK_WIDTH = '--koalasync-chat-dock-width';
const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥']);
const MAX_REACTION_PARTICLES = 36;
const MAX_RENDERED_ACTIVITY_IDS = 200;
const LAUNCHER_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAnsSURBVGhD7VlbbBtZGfZyF7cFJBrPjGdsz8W3Tnwb23HSdM3SXZFuBNUu21C6Tanapk3dNM390sS5NGna7IUXFoTElpdVn5HgofAKSCAegG2FhLQFVruoK3gAJARiKeSg74zP+PiMnYRukXjoL/2yPXPO+b//ev5zHAg8pIf0kNqSafZ9sKurEikWe2P5/H4pEAi8RxwDOnz48HsLhe6c4/QMhxTrG6FQ7LuhkPVjNWS9pirWLVWN/1RT4zdVxfpWJlMad5yuXqwtrgOqVCrvg0ysl8+XO8vl8qfEMdsSFnCcfVoknHpFUxP/kIIRIgWjRJb0rWik846lp76SSlU+GggEHnEc51FdT52IhFO/lyWdKLJJQorLimxwjN8uy7KB9bbUUPzPppkaz2Qqn8BapVLp47puj4a1xFvBjsgW5IEV2dzSI/YP0+lCLpVKfUDE20SO43xYVazrUjDyVwgKhSwSjdjE0NMkrCVISLGoIsFg9Je2XfyqFIz+phVQVwGe2Tt+rPusY4/2pq7bXw92hG/JsrGF5+FwkiTiDjGNDFFDMTpOkiL3FMX8Tj0S/NTV9ZilqbHXYUmAHRsbJqurM+Tq1UWPa7UJUijshxI+gFCYZ/pM0v1cBy56CsZ5/PE+srIy3SRzfX2eTE+PkEg4RedrauIvuVxPoQk83KipsdtYJJ/tIWtr82RjY4FcuXKJfrLF2O+JiarrDcGisJxl5sjJk8+R8fGzZGZmhMzOjpCJiWEyNDRI0naZRCN7fYrDylNT58m1a7Um8AyDK3eRPPnEQSpXkc03SqX9UQo+nX7yI4psfR/W2N97gC6CCQwwvLCyMkXW1ua85+Dz1VOeJ/SoTebnxz2hvNKteGlpmhh6hnoAa5w9e6JpDqzOy2XPge3Qoaddj4ViP+/t7f1kIJ0u9kmS/k9D7/QG4XNxcYxUq8fIuXPPkTNnjpDh4aPk0qVRDySEaGqCupdXbDfMLLuwME6Vx1rMu7OzVSoXMs+e/TL9DixsLsYhJ2VJ/7dp2scCmpq4CSswIMwStdo4uXDhBF2EMZSYmal649jYnSwuhgabw8/HmtPTw9RgkMPkXbx4sikXMQ4eRNhpWvK1ALIbCly+POtZAgNhCViBWQKL4fvQ0JfoOBHQdtxKAZERKszTrYyGMczz+ERSI5QCkqTfgQLBjghVAgMRKuIi7Hu1OugT/iAYoGD9VjLxfX7ejRAoIElRmj+aGn8zkExmR6HAvu7PeqEhhg6/kFhawczC+GQsjtkNwwt8+PBKQDmMAb4jR464VU+NbwbUUPx7UGB+/qIXPsvLkzT2eOBw79TUcJPA3YLdzRiWE8gD3gOQe/78cVqRNjeX6diVlRnqAVWN/ySgyObr0ObyZZSrhgVZMjF+N6HDK7AbL6HyMOOxHMRzKID9APsU3aEV8y6q0A3UclWNk1OnBuuDFihgAGdxiaQWBb0b3k4BxDvveSiE6MCcavUUCWtJNwe0xM0AOj5FsX4FJWJW3itpcNnk5BkaSnNzIzuWygfJkIVCMj5+moYU8o6FWCJeoJunGrJ+ncmUS3Q3dhzn/VoofgeawT1gfsHtrHU/43Y7Hu8REfhEgiPMsXkqsnEXmJv6IUnSX0Iyo9dAv7K5ueJNFhdmvLFRI+vr/uf8+42NJZpb4jv3/dK288Gu/BpKJg0btPbBPeGvNYEHZbPoRhNvS8HoO3ARGrNUskC37aWlKd/CX/j8Ia/DtFOlOiDXzUtLk9QItDOl3W2SNnXsPcYePNjvdazxmONbH9ZGO20aaYoFmGRJfyesJu86TndCxE8pm+39dLHYO+T24G6HGQxGvBjkBdS7Qjce1bhn5eXlKd+Bhp4tFIv2PsyqbCd135nk2jV4qrE+NiycRxodr0kOHOh7ERhF3E2Uz3d/joHD5GBH2Acev91YdBUAGPYun9vHnRcaBxk8gzdbKQCDtQpVhA1vCMvqXBTx+iib7X6WtxxiTlwcCqDXZwqMjAx55ReHoQbw5kMPLMrmT06eY2FB54vgwUxJhsUw9l4V8foony8/zWuNM7GogKgM+45Q410uKgCwrfKpFUMmcsebSxXoXBXx+iiX6z3AhLIDB2vydmIkamsF3N9Ya2LinG9eK4Zh2HmYKZDPl8dEvD7CxoakZAdzhNDc3Khn6e28gTOzP/5dZhWJeWC7dcCrq7OcAu562WzpWRGvj9Lp7j1sq3ZDKEqOHRvwtvKdBLfLAT5Z+XXarbewMOGbXyh02yJeH2GHi4RTuI/xXAfLoSL09x+iluGFimAuXDjNeaD5lmJw8EiTAiJ4/nc+11uf564V7Ij+acd7IUaalvxjKytSS0g6scwsWVtzW28RDD4XFye9sWB4cWTkDHnhhVVvTLtdnr1jVmdGUBTzZRFnWwop5i/aKUAXpHFuED3auAhg/PzzyxQA+qnFxQnK+I53UKCV5XnG3NOnjzcSH/KC+t/K5Yop4mxLsmze9ANvKABGjqB75S0PxvYPxaamqrR3xw5bq02STrtEb/nYoYTn9fVL9B5pYOCLpK+vv2mThAIhxfpRpVL5kIizLSmScd0PvG59JKMaJ9PT7kWUaM3BY+5xD+fW+p2qZ02+52Fz5+YueuW6UYIb3sd1YzpdOili3JbkoH5FtDhjCMIVDHO3qAAYpRKXZLhrgtUBHFWF3/Qwd2ZmtAGe44YCBprC277WeSfS9eSoB5irIsylKJULC2NtFWAWxvtWIQNGtwlPMhm44EKlayjgshqy7mFvEjFuS4lE+hlWPtFey5L+VmODchk1fXLyvK/RE3OilZKYMzDwTBNYVC7kwt5UwfVKXT5YVWI38B+EiLMt2XaxIEvRe7JsvJHNdvfkcl1pRTaaSisTAMuxE5wImmdeOWyKSHY+5nHbxt6jctWv8l0vqLFblQr9T2J3hIy37Xw/fxcPJWTZuM1CyuO6ZxDvuLVG1WkVNkwxAC13PVY/TzTCc3l5xpsHD7GbB/peMX/rOE882ozyPiif7zEi4dTPqHd8VYPFbIyWUZTYnu7PkKee6ie23UU3P/dk5Z+DdWo1994HjN0+mSh4BsIBvqen52MinvsiXMdHo4lxRTaoEgDAGj++1IrcANyiNEs6zQFYf35+jCY37+GQEvvBf12JdiL88RdWE6/KUvQPimxsiWD9oP3K8N+PHh2g+dQ8jzaT/7Lt4oAo/4EQKkOxWAzqenI6GtmLSoU/5nwgPPD1qhIJp/6uqYnr+VzPi/gTr2kMG+c+21IUc73dv6MPlODiUmlfJpstHw+H4yuSpL8sy/qrcjB6QwpGv61I5kuZTGkkmy1X8I9kfdojdjI3rCjm7yhYrgGUZePtiJao4Y9HQdT/J3V2OofCWvKbYTX+SiKRrXJKPqSH9L+k/wCILgNgmPdaeAAAAABJRU5ErkJggg==';
const SIZE_PRESETS = Object.freeze({
compact: Object.freeze({ width: 320, height: 400 }),
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
@@ -48,8 +56,13 @@
let chatPosition = 'right';
let chatSize = 'standard';
let chatStartMode = 'bubble';
let chatReactionDisplay = 'chat';
let themeMode = 'system';
let themePalette = 'eucalyptus';
let pageDockTarget = null;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const renderedActivityIds = new Set();
const renderedActivityIdOrder = [];
const host = document.createElement('div');
host.id = 'koalasync-chat-overlay-host';
@@ -114,12 +127,22 @@
.launcher {
position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong);
border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer;
pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px;
pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32);
touch-action: none; user-select: none;
}
.launcher.docked-left { left: 0 !important; right: auto !important; border-left: 0; border-radius: 0 16px 16px 0; }
.launcher.docked-right { left: auto !important; right: 0 !important; border-right: 0; border-radius: 16px 0 0 16px; }
.launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
.launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
.launcher-icon { pointer-events: none; }
.launcher-icon { display: block; width: 36px; height: 36px; margin: auto; pointer-events: none; }
.launcher-bubble-mark {
position: absolute; right: 3px; bottom: 3px; width: 15px; height: 12px;
border: 2px solid var(--card); border-radius: 7px; background: var(--accent); pointer-events: none;
}
.launcher-bubble-mark::after {
content: ""; position: absolute; right: 0; bottom: -4px;
width: 5px; height: 5px; background: var(--accent); clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.unread {
position: absolute; top: -7px; right: -7px; display: none; min-width: 22px; height: 22px;
align-items: center; justify-content: center; padding: 0 6px; border: 2px solid var(--card);
@@ -158,6 +181,13 @@
.event-message::before, .event-message::after { content: ""; flex: 1; height: 1px; background: var(--border-soft); }
.event-text { max-width: 78%; }
.composer { padding: 10px; border-top: 1px solid var(--border-soft); background: var(--card); }
.quick-reactions { display: flex; align-items: center; gap: 5px; margin-bottom: 8px; }
.reaction-button {
width: 34px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px;
background: var(--surface-alt); cursor: pointer; font-size: 18px; line-height: 1;
}
.reaction-button:hover:not(:disabled), .reaction-button:focus-visible { border-color: var(--accent); transform: translateY(-1px); }
.reaction-button:disabled { opacity: .5; cursor: wait; }
textarea { width: 100%; min-height: 62px; max-height: 132px; resize: vertical; border: 1px solid var(--border-strong); border-radius: 11px; padding: 9px; background: var(--surface-deep); color: var(--text); outline: none; }
textarea:focus { border-color: var(--accent); }
textarea::placeholder { color: var(--text-muted); }
@@ -167,9 +197,24 @@
.send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; }
.send:hover:not(:disabled) { background: var(--accent-hover); }
.send:disabled { opacity: .55; cursor: wait; }
.reaction-layer { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 1; }
.reaction-particle {
position: absolute; top: -12vh; font-size: clamp(24px, 4vw, 48px);
filter: drop-shadow(0 4px 7px rgb(0 0 0 / .35));
animation: reaction-fall var(--reaction-duration) cubic-bezier(.22,.62,.45,1) var(--reaction-delay) forwards;
}
.launcher, .panel { z-index: 2; }
@keyframes reaction-fall {
0% { transform: translate3d(0, -8vh, 0) rotate(var(--reaction-start-rotation)); opacity: 0; }
12% { opacity: 1; }
100% { transform: translate3d(var(--reaction-drift), 116vh, 0) rotate(var(--reaction-end-rotation)); opacity: .92; }
}
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
.reaction-particle { animation: none !important; display: none !important; }
}
`;
function element(tag, className, text) {
@@ -183,10 +228,14 @@
app.id = 'app';
const launcher = element('button', 'launcher');
launcher.type = 'button';
const launcherIcon = element('span', 'launcher-icon', '💬');
const launcherIcon = element('img', 'launcher-icon');
launcherIcon.src = LAUNCHER_ICON_DATA_URL;
launcherIcon.alt = '';
const launcherBubbleMark = element('span', 'launcher-bubble-mark');
launcherBubbleMark.setAttribute('aria-hidden', 'true');
const unreadBadge = element('span', 'unread');
unreadBadge.setAttribute('aria-hidden', 'true');
launcher.append(launcherIcon, unreadBadge);
launcher.append(launcherIcon, launcherBubbleMark, unreadBadge);
const launcherHint = element('span', 'visually-hidden');
launcherHint.id = 'chat-launcher-hint';
const panel = element('section', 'panel');
@@ -209,13 +258,22 @@
const empty = element('div', 'empty');
messages.append(empty);
const composer = element('form', 'composer');
const quickReactions = element('div', 'quick-reactions');
quickReactions.setAttribute('role', 'group');
const reactionButtons = QUICK_REACTIONS.map(reaction => {
const button = element('button', 'reaction-button', reaction);
button.type = 'button';
button.dataset.reaction = reaction;
quickReactions.append(button);
return button;
});
const textareaLabel = element('label', 'visually-hidden');
textareaLabel.htmlFor = 'chat-composer-input';
const textarea = element('textarea');
textarea.id = 'chat-composer-input';
textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status');
const composerRow = element('div', 'composer-row');
const count = element('span', 'count', '0/500');
const count = element('span', 'count', `0/${MAX_COMPOSER_CODE_POINTS}`);
count.id = 'chat-composer-count';
const sendButton = element('button', 'send');
sendButton.type = 'submit';
@@ -223,21 +281,53 @@
const status = element('div', 'status');
status.id = 'chat-composer-status';
status.setAttribute('role', 'status');
composer.append(textareaLabel, textarea, composerRow, status);
composer.append(quickReactions, textareaLabel, textarea, composerRow, status);
panel.append(header, messages, composer);
app.append(launcherHint, launcher, panel);
const reactionLayer = element('div', 'reaction-layer');
reactionLayer.setAttribute('aria-hidden', 'true');
app.append(reactionLayer, launcherHint, launcher, panel);
shadow.append(style, app);
document.documentElement.append(host);
const pageDockStyle = document.createElement('style');
pageDockStyle.id = 'koalasync-chat-page-dock-style';
pageDockStyle.textContent = `
html[${PAGE_DOCK_ATTRIBUTE}="left"] {
html[${PAGE_DOCK_ATTRIBUTE}] {
box-sizing: border-box !important;
padding-left: var(${PAGE_DOCK_WIDTH}) !important;
width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
overflow-x: clip !important;
}
html[${PAGE_DOCK_ATTRIBUTE}="left"] {
margin-left: var(${PAGE_DOCK_WIDTH}) !important;
margin-right: 0 !important;
}
html[${PAGE_DOCK_ATTRIBUTE}="right"] {
margin-left: 0 !important;
margin-right: var(${PAGE_DOCK_WIDTH}) !important;
}
html[${PAGE_DOCK_ATTRIBUTE}] > body {
width: 100% !important;
max-width: 100% !important;
min-height: 100vh !important;
overflow-x: clip !important;
clip-path: inset(0) !important;
contain: layout paint !important;
}
[${PAGE_DOCK_ATTRIBUTE}]:not(html) > :not(#koalasync-chat-overlay-host) {
box-sizing: border-box !important;
padding-right: var(${PAGE_DOCK_WIDTH}) !important;
width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
overflow-x: clip !important;
clip-path: inset(0) !important;
contain: layout paint !important;
}
[${PAGE_DOCK_ATTRIBUTE}="left"]:not(html) > :not(#koalasync-chat-overlay-host) {
margin-left: var(${PAGE_DOCK_WIDTH}) !important;
margin-right: 0 !important;
}
[${PAGE_DOCK_ATTRIBUTE}="right"]:not(html) > :not(#koalasync-chat-overlay-host) {
margin-left: 0 !important;
margin-right: var(${PAGE_DOCK_WIDTH}) !important;
}
`;
document.documentElement.append(pageDockStyle);
@@ -263,6 +353,16 @@
});
}
function setLocalStorage(values) {
if (destroyed) return;
try {
const result = chrome.storage.local.set(values);
if (result?.catch) result.catch(() => {});
} catch (_) {
// Extension context was invalidated while the page remained open.
}
}
function strings() {
return context?.strings || {};
}
@@ -292,6 +392,12 @@
detachedButton.setAttribute('aria-label', text.detached || '');
rightButton.setAttribute('aria-label', text.dockRight || '');
closeButton.setAttribute('aria-label', text.close || '');
quickReactions.setAttribute('aria-label', text.quickReactions || text.title || '');
for (const button of reactionButtons) {
const label = `${text.quickReactions || text.title || ''} ${button.dataset.reaction}`.trim();
button.title = label;
button.setAttribute('aria-label', label);
}
applyUnreadCount();
}
@@ -340,18 +446,24 @@
}
function clearPageDock() {
document.documentElement.removeAttribute(PAGE_DOCK_ATTRIBUTE);
document.documentElement.style.removeProperty(PAGE_DOCK_WIDTH);
const targets = new Set([pageDockTarget, document.documentElement].filter(Boolean));
for (const target of targets) {
target.removeAttribute(PAGE_DOCK_ATTRIBUTE);
target.style.removeProperty(PAGE_DOCK_WIDTH);
}
pageDockTarget = null;
}
function applyPageDock(side, width) {
const viewport = viewportBounds();
if (!opened || document.fullscreenElement || viewport.width - width < DOCK_MIN_PAGE_WIDTH) {
if (!opened) {
clearPageDock();
return;
}
document.documentElement.setAttribute(PAGE_DOCK_ATTRIBUTE, side);
document.documentElement.style.setProperty(PAGE_DOCK_WIDTH, `${width}px`);
const target = document.fullscreenElement || document.documentElement;
if (pageDockTarget && pageDockTarget !== target) clearPageDock();
pageDockTarget = target;
target.setAttribute(PAGE_DOCK_ATTRIBUTE, side);
target.style.setProperty(PAGE_DOCK_WIDTH, `${width}px`);
}
function applyUnreadCount() {
@@ -382,18 +494,27 @@
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
chrome.storage.local.set({ [storageKey]: layout }).catch(() => {});
setLocalStorage({ [storageKey]: layout });
}, 150);
}
function applyLayout() {
if (destroyed) return;
applyingLayout = true;
const size = preferredSize();
initializeLauncher();
clampLauncher();
launcher.style.left = `${layout.launcherX}px`;
launcher.style.right = 'auto';
launcher.style.top = `${layout.launcherY}px`;
launcher.classList.toggle('docked-left', layout.mode === 'left');
launcher.classList.toggle('docked-right', layout.mode === 'right');
if (layout.mode === 'detached') {
initializeLauncher();
clampLauncher();
launcher.style.left = `${layout.launcherX}px`;
launcher.style.right = 'auto';
launcher.style.top = `${layout.launcherY}px`;
} else {
launcher.style.left = layout.mode === 'left' ? '0' : 'auto';
launcher.style.right = layout.mode === 'right' ? '0' : 'auto';
launcher.style.top = `${Math.max(0, Math.round(window.innerHeight / 2 - LAUNCHER_SIZE / 2))}px`;
}
panel.style.right = 'auto';
panel.style.left = 'auto';
panel.style.bottom = 'auto';
@@ -436,7 +557,7 @@
layout.mode = mode;
applyLayout();
saveLayout();
if (persistPreference) chrome.storage.local.set({ chatPosition: mode }).catch(() => {});
if (persistPreference) setLocalStorage({ chatPosition: mode });
}
function setSize(size, persistPreference = true) {
@@ -452,7 +573,7 @@
if (layout.mode === 'detached') clampDetached();
applyLayout();
saveLayout();
if (persistPreference) chrome.storage.local.set({ chatSize }).catch(() => {});
if (persistPreference) setLocalStorage({ chatSize });
}
function setOpened(next) {
@@ -490,6 +611,9 @@
}
applyStrings();
applyLayout();
if (context?.enabled && Array.isArray(context.activity)) {
for (const event of context.activity) appendEvent(event);
}
}
async function refresh() {
@@ -523,10 +647,12 @@
messages.append(wrapper);
while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove();
messages.scrollTop = messages.scrollHeight;
if (QUICK_REACTIONS.includes(message.text.trim())) triggerReactionRain(message.text.trim());
}
function appendEvent(event) {
if (!context?.enabled || !event || typeof event.action !== 'string') return;
if (typeof event.id === 'string' && renderedActivityIds.has(event.id)) return;
const own = event.senderId === context.peerId;
const displayName = own ? (strings().you || '') : (event.username || event.senderId || '');
const labels = {
@@ -546,6 +672,13 @@
.replace('{action}', labels[event.action]);
}
if (!eventText) return;
if (typeof event.id === 'string') {
renderedActivityIds.add(event.id);
renderedActivityIdOrder.push(event.id);
while (renderedActivityIdOrder.length > MAX_RENDERED_ACTIVITY_IDS) {
renderedActivityIds.delete(renderedActivityIdOrder.shift());
}
}
if (empty.isConnected) empty.remove();
if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1);
@@ -562,17 +695,44 @@
function clearMessages() {
messages.replaceChildren(empty);
renderedActivityIds.clear();
renderedActivityIdOrder.length = 0;
setUnreadCount(0);
}
function triggerReactionRain(reaction) {
if (chatReactionDisplay !== 'video' || reducedMotion.matches || !QUICK_REACTIONS.includes(reaction)) return;
const available = Math.max(0, MAX_REACTION_PARTICLES - reactionLayer.childElementCount);
const particleCount = Math.min(10, available);
for (let index = 0; index < particleCount; index++) {
const particle = element('span', 'reaction-particle', reaction);
particle.style.left = `${4 + Math.random() * 88}%`;
particle.style.setProperty('--reaction-duration', `${2.8 + Math.random() * 1.6}s`);
particle.style.setProperty('--reaction-delay', `${Math.random() * .7}s`);
particle.style.setProperty('--reaction-drift', `${-45 + Math.random() * 90}px`);
particle.style.setProperty('--reaction-start-rotation', `${-35 + Math.random() * 70}deg`);
particle.style.setProperty('--reaction-end-rotation', `${-160 + Math.random() * 320}deg`);
particle.addEventListener('animationend', () => particle.remove(), { once: true });
reactionLayer.append(particle);
}
}
function updateComposerState() {
const length = [...textarea.value].length;
count.textContent = `${length}/${MAX_COMPOSER_CODE_POINTS}`;
count.style.color = length > MAX_COMPOSER_CODE_POINTS ? 'var(--danger)' : '';
status.textContent = length > MAX_COMPOSER_CODE_POINTS ? (strings().tooLong || '') : '';
return length;
}
function resetComposer() {
sendGeneration++;
sending = false;
textarea.value = '';
count.textContent = '0/500';
count.style.color = '';
updateComposerState();
status.textContent = '';
sendButton.disabled = false;
for (const button of reactionButtons) button.disabled = false;
}
let launcherDrag = null;
@@ -586,6 +746,7 @@
setOpened(true);
});
launcher.addEventListener('pointerdown', event => {
if (layout.mode !== 'detached') return;
launcherDrag = {
pointerId: event.pointerId,
startX: event.clientX,
@@ -622,12 +783,7 @@
detachedButton.addEventListener('click', () => setMode('detached'));
rightButton.addEventListener('click', () => setMode('right'));
textarea.addEventListener('input', () => {
const length = [...textarea.value].length;
count.textContent = `${length}/500`;
count.style.color = length > 500 ? 'var(--danger)' : '';
status.textContent = length > 500 ? (strings().tooLong || '') : '';
});
textarea.addEventListener('input', updateComposerState);
const stopPageKeyboardShortcut = event => {
const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
if (!path.includes(textarea) && !path.includes(composer)) return;
@@ -640,33 +796,62 @@
for (const eventName of ['keydown', 'keyup', 'keypress']) {
window.addEventListener(eventName, stopPageKeyboardShortcut, true);
}
composer.addEventListener('submit', async event => {
event.preventDefault();
async function sendChatText(text, clearComposerValue = false) {
if (sending || !context?.enabled) return;
const submittedValue = textarea.value;
const text = textarea.value.trim();
text = String(text || '').trim();
if (!text) return;
if ([...text].length > 500) {
if ([...text].length > MAX_COMPOSER_CODE_POINTS) {
status.textContent = strings().tooLong || '';
return;
}
let chunks;
try {
chunks = globalThis.KoalaSyncChatFormat?.splitChatText(
text,
MAX_MESSAGE_CODE_POINTS,
MAX_CHAT_CHUNKS
);
} catch (_) {
status.textContent = strings().tooLong || '';
return;
}
if (!Array.isArray(chunks) || chunks.length === 0) return;
const generation = ++sendGeneration;
sending = true;
sendButton.disabled = true;
for (const button of reactionButtons) button.disabled = true;
status.textContent = '';
const response = await messageRuntime({ type: 'CHAT_SEND', text });
if (destroyed || generation !== sendGeneration) return;
let completedChunks = 0;
let response = null;
for (const chunk of chunks) {
response = await messageRuntime({ type: 'CHAT_SEND', text: chunk });
if (destroyed || generation !== sendGeneration) return;
if (response?.status !== 'ok') break;
completedChunks++;
}
sending = false;
sendButton.disabled = false;
if (response?.status === 'ok') {
if (textarea.value === submittedValue) {
for (const button of reactionButtons) button.disabled = false;
if (completedChunks === chunks.length) {
if (clearComposerValue && textarea.value.trim() === text) {
textarea.value = '';
count.textContent = '0/500';
updateComposerState();
}
} else {
if (clearComposerValue && textarea.value.trim() === text) {
textarea.value = chunks.slice(completedChunks).join('\n');
updateComposerState();
}
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
}
}
composer.addEventListener('submit', async event => {
event.preventDefault();
await sendChatText(textarea.value, true);
});
for (const button of reactionButtons) {
button.addEventListener('click', () => sendChatText(button.dataset.reaction));
}
let drag = null;
header.addEventListener('pointerdown', event => {
@@ -698,7 +883,7 @@
layout.customHeight = rect.height;
if (chatSize !== 'custom') {
chatSize = 'custom';
chrome.storage.local.set({ chatSize }).catch(() => {});
setLocalStorage({ chatSize });
}
clampDetached();
saveLayout();
@@ -725,7 +910,7 @@
}
function handleStorage(changes, area) {
if (area !== 'local') return;
if (destroyed || area !== 'local') return;
if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system';
if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus';
if (changes.themeMode || changes.themePalette) applyTheme();
@@ -738,6 +923,9 @@
setOpened(chatStartMode === 'open');
}
}
if (changes.chatReactionDisplay) {
chatReactionDisplay = changes.chatReactionDisplay.newValue === 'video' ? 'video' : 'chat';
}
if (changes.locale) refresh();
}
@@ -750,7 +938,7 @@
resetComposer();
refresh();
}
if (message?.type === 'CHAT_DESTROY') destroy();
if (message?.type === 'CHAT_DESTROY' || message?.type === 'TARGET_DEACTIVATE') destroy();
}
function destroy() {
@@ -768,8 +956,8 @@
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
window.removeEventListener('resize', handleResize);
systemTheme.removeEventListener('change', handleSystemTheme);
chrome.storage.onChanged.removeListener(handleStorage);
chrome.runtime.onMessage.removeListener(handleRuntime);
try { chrome.storage.onChanged.removeListener(handleStorage); } catch (_) { /* invalidated context */ }
try { chrome.runtime.onMessage.removeListener(handleRuntime); } catch (_) { /* invalidated context */ }
host.remove();
window.koalaSyncChatOverlay = null;
}
@@ -779,7 +967,8 @@
systemTheme.addEventListener('change', handleSystemTheme);
chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode'], data => {
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
if (destroyed) return;
const storedLayout = data[storageKey];
if (storedLayout && typeof storedLayout === 'object') {
layout = { ...layout, ...storedLayout };
@@ -789,6 +978,7 @@
chatPosition = normalizePosition(data.chatPosition);
chatSize = normalizeSize(data.chatSize);
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
chatReactionDisplay = data.chatReactionDisplay === 'video' ? 'video' : 'chat';
layout.mode = chatPosition;
if (layout.mode === 'detached') layout.detachedInitialized = true;
const preset = SIZE_PRESETS[chatSize];
+54 -1
View File
@@ -1,6 +1,7 @@
export const MAX_ROOM_ID_LENGTH = 64;
export const CHAT_SEND_LIMIT = 9;
export const CHAT_SEND_LIMIT = 10;
export const CHAT_SEND_WINDOW_MS = 10000;
export const CHAT_ECHO_TIMEOUT_MS = 4000;
export function normalizeRoomId(value) {
if (typeof value !== 'string') return '';
@@ -33,6 +34,58 @@ export function createChatSendLimiter({
};
}
export function createChatEchoTracker({
timeoutMs = CHAT_ECHO_TIMEOUT_MS,
setTimer = globalThis.setTimeout,
clearTimer = globalThis.clearTimeout
} = {}) {
const pending = new Map();
function settle(ciphertext, acknowledged) {
const entry = pending.get(ciphertext);
if (!entry) return false;
pending.delete(ciphertext);
clearTimer(entry.timer);
entry.resolve(acknowledged);
return true;
}
return {
waitFor(ciphertext) {
if (typeof ciphertext !== 'string' || !ciphertext) return Promise.resolve(false);
settle(ciphertext, false);
return new Promise(resolve => {
const timer = setTimer(() => {
pending.delete(ciphertext);
resolve(false);
}, timeoutMs);
pending.set(ciphertext, { resolve, timer });
});
},
acknowledge(ciphertext) {
return settle(ciphertext, true);
},
cancel(ciphertext) {
return settle(ciphertext, false);
},
reset() {
for (const ciphertext of [...pending.keys()]) settle(ciphertext, false);
}
};
}
export function shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo }) {
if (!enabled) return false;
const normalizedTargetTabId = Number(targetTabId);
const targetIsFocused = Number.isInteger(normalizedTargetTabId)
&& tab?.id === normalizedTargetTabId
&& tab.active === true
&& Number.isInteger(tab.windowId)
&& windowInfo?.id === tab.windowId
&& windowInfo.focused === true;
return !targetIsFocused;
}
export function createLatestTaskQueue() {
let generation = 0;
let tail = Promise.resolve();
+50 -2
View File
@@ -1,10 +1,14 @@
import { describe, expect, it } from 'vitest';
import { CHAT_MESSAGE_RATE_LIMIT } from '../server/rate-limiter.js';
import {
CHAT_ECHO_TIMEOUT_MS,
CHAT_SEND_LIMIT,
createChatEchoTracker,
createChatSendLimiter,
createLatestTaskQueue,
MAX_ROOM_ID_LENGTH,
normalizeRoomId
normalizeRoomId,
shouldShowChatNotification
} from './chat-session.js';
describe('chat session boundaries', () => {
@@ -14,7 +18,8 @@ describe('chat session boundaries', () => {
expect(normalizeRoomId(null)).toBe('');
});
it('keeps client chat bursts below the relay disconnect threshold', () => {
it('allows ten-message client bursts without exceeding the relay threshold', () => {
expect(CHAT_SEND_LIMIT).toBe(CHAT_MESSAGE_RATE_LIMIT);
let current = 1000;
const limiter = createChatSendLimiter({ now: () => current });
for (let index = 0; index < CHAT_SEND_LIMIT; index++) {
@@ -27,6 +32,49 @@ describe('chat session boundaries', () => {
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
});
it('waits for the matching relay echo and clears pending sends on reset or timeout', async () => {
const timers = new Map();
let nextTimerId = 1;
const tracker = createChatEchoTracker({
setTimer(callback, timeoutMs) {
const id = nextTimerId++;
timers.set(id, { callback, timeoutMs });
return id;
},
clearTimer(id) {
timers.delete(id);
}
});
const acknowledged = tracker.waitFor('ciphertext-1');
expect(tracker.acknowledge('ciphertext-1')).toBe(true);
await expect(acknowledged).resolves.toBe(true);
const reset = tracker.waitFor('ciphertext-2');
tracker.reset();
await expect(reset).resolves.toBe(false);
const timedOut = tracker.waitFor('ciphertext-3');
const timeout = [...timers.values()][0];
expect(timeout.timeoutMs).toBe(CHAT_ECHO_TIMEOUT_MS);
timeout.callback();
await expect(timedOut).resolves.toBe(false);
});
it('suppresses chat notifications only while the target tab and its window are focused', () => {
const focused = {
enabled: true,
targetTabId: 7,
tab: { id: 7, active: true, windowId: 3 },
windowInfo: { id: 3, focused: true }
};
expect(shouldShowChatNotification(focused)).toBe(false);
expect(shouldShowChatNotification({ ...focused, windowInfo: { id: 3, focused: false } })).toBe(true);
expect(shouldShowChatNotification({ ...focused, tab: { id: 7, active: false, windowId: 3 } })).toBe(true);
expect(shouldShowChatNotification({ ...focused, targetTabId: 8 })).toBe(true);
expect(shouldShowChatNotification({ ...focused, enabled: false })).toBe(false);
});
it('serializes join work and prevents an older request from winning storage races', async () => {
const queue = createLatestTaskQueue();
let releaseFirst;
+289 -131
View File
@@ -12,7 +12,34 @@
// Injection Guard: Check if already injected AND context is valid
try {
try {
if (window.koalaSyncInjected && chrome.runtime.id) {
return;
}
} catch (_e) {
// Context invalidated, proceed with re-injection
}
window.koalaSyncInjected = true;
let destroyed = false;
const lifecycleTimeouts = new Set();
const seekPollTimers = new Set();
const attachedVideos = new Set();
function scheduleLifecycleTimeout(callback, delay) {
const timer = setTimeout(() => {
lifecycleTimeouts.delete(timer);
if (!destroyed) callback();
}, delay);
lifecycleTimeouts.add(timer);
return timer;
}
function runtimeMessage(message, callback) {
if (destroyed) return Promise.resolve(undefined);
@@ -62,15 +89,16 @@
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
function _setSuppress(state) {
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
_suppressTimers[state] = setTimeout(() => {
@@ -210,17 +238,20 @@
if (!Number.isFinite(targetTime)) return targetTime;
const siteTimeline = getSiteQuirkTimeline(video);
if (!siteTimeline) return targetTime;
if (!siteTimeline) return targetTime;
const nativeTarget = siteTimeline.nativeStart + targetTime * siteTimeline.nativeScale;
const max = Number.isFinite(siteTimeline.end) ? siteTimeline.end : nativeTarget;
const min = Number.isFinite(siteTimeline.start) ? siteTimeline.start : 0;
return Math.max(min, Math.min(max, nativeTarget));
}
function shouldUsePageApiSeek() {
function shouldUsePageApiSeek() {
return window.KOALA_PAGE_API_SEEK_ENABLED === true &&
typeof window.koalaFindPageApiSeekProvider === 'function' &&
!!window.koalaFindPageApiSeekProvider(window.location.hostname);
return window.KOALA_PAGE_API_SEEK_ENABLED === true &&
typeof window.koalaFindPageApiSeekProvider === 'function' &&
!!window.koalaFindPageApiSeekProvider(window.location.hostname);
}
function seekVideo(video, targetTime) {
// Prefer a precise page-level seek API when available (Netflix, Disney+);
// for those players the DOM/button seek path is imprecise or impossible.
if (shouldUsePageApiSeek()) {
expectedSeekTime = targetTime;
@@ -234,8 +265,9 @@
// --- Play/Pause Coalescing (leading + trailing) ---
// Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps,
// page teardown) fire bursts of native play/pause events within a few hundred
// ms. Relaying each as a distinct command spams peers and the relay.
@@ -320,9 +352,9 @@
// --- Host Control Mode (guest-side) ---
}
// When a room is in 'host-only' mode and we're a guest, a deliberate local
});
// pause/seek must not drive the room (background/server already drop it). Here
// we handle the *local* UX: snap back to the host's position, or — if the user
@@ -355,7 +387,7 @@
body: 'Only the host can control playback in this room. Keep watching together, or watch on your own?',
stay: 'Stay in sync',
solo: 'Watch on my own',
@@ -457,7 +489,7 @@
}
// Snap the local player back to the host's current position/state.
@@ -466,7 +498,7 @@
if (hcmDesynced) return; // user opted out — never yank them back automatically
hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS;
const video = findVideo();
if (!video) return;
@@ -480,14 +512,16 @@
// Adopt the host's play/pause state — but ONLY if we actually know it.
// Defaulting to PLAY when the state is unknown would auto-resume a paused
if (target && Number.isFinite(target.targetTime)) {
tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime });
}
// video against the host's real state (H-1).
if (target && target.playbackState === 'paused') {
tryMediaAction(EVENTS.PAUSE);
} else if (target && target.playbackState === 'playing') {
tryMediaAction(EVENTS.PLAY);
}
@@ -518,12 +552,12 @@
else reportLog('Host-only: resync requested but host state unavailable', 'warn');
return;
let attempts = 0;
}
hcmSnapBackToHost(res.target);
});
};
@@ -786,7 +820,7 @@
hcmShowBadge();
}
hcmDesynced = true;
function hcmExitDesync() {
@@ -810,7 +844,7 @@
}
hcmRemoveBadge();
function hcmShowBadge() {
@@ -923,36 +957,52 @@
// Scan likely media hosts even when light-DOM videos exist; many players
function reportLog(message, level = 'info') {
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
}
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
for (const el of potentialHosts) {
if (el.shadowRoot) {
const found = findVideo(el.shadowRoot);
if (found) candidates.push(found);
}
}
if (candidates.length === 0) return null;
// Multiple videos found → pick the best one
const candidates = Array.from(root.querySelectorAll('video'));
// Scan likely media hosts even when light-DOM videos exist; many players
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
if (candidates.length === 1) return candidates[0];
let best = null;
let bestScore = -1;
for (const v of candidates) {
if (v.tagName !== 'VIDEO') continue;
// Score: visible area + bonus for unmuted + bonus for longer duration
const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0);
const unmutedBonus = v.muted ? 0 : 100000;
const durationBonus = (v.duration && isFinite(v.duration) ? v.duration : 0) * 100;
const score = area + unmutedBonus + durationBonus;
if (score > bestScore) {
bestScore = score;
@@ -1019,7 +1069,7 @@
...safeSettings,
compressor: {
...DEFAULT_AUDIO_SETTINGS.compressor,
@@ -1037,7 +1087,7 @@
};
}
@@ -1045,15 +1095,15 @@
if (!audioCtx) {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
audioCtx = new AudioContextClass({ latencyHint: 'interactive' });
} catch (e) {
reportLog(`Audio Processing unavailable: ${e.message}`, 'warn');
@@ -1071,7 +1121,7 @@
return audioCtx;
}
@@ -1082,7 +1132,7 @@
audioCtx.close().catch(() => {});
audioCtx = null;
if (!audioCtx) {
}
audioChains = new WeakMap();
@@ -1265,14 +1315,16 @@
: null;
}
// Extract a canonical episode identifier from a title string.
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
chain.compressor.release.value = params.release ?? 0.300;
// Returns null if no episode pattern found.
// --- SHARED_EPISODE_UTILS_INJECT_START ---
// This block is automatically replaced by /scripts/build-extension.cjs
@@ -1280,7 +1332,7 @@
if (!title || typeof title !== 'string') return null;
rampGain(chain.dryGain, 0, t);
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
@@ -1308,8 +1360,9 @@
if (idA || idB) return false;
// Extract a canonical episode identifier from a title string.
return titleA === titleB;
}
// --- SHARED_EPISODE_UTILS_INJECT_END ---
@@ -1373,42 +1426,64 @@
function onEpisodeTransition(newTitle) {
return idA !== idB; // Both parseable → only block if different
// Debounce: prevent duplicate fires from multiple signals
if (episodeTransitionDebounce) return;
episodeTransitionDebounce = setTimeout(() => {
function checkEpisodeTransition() {
const currentTitle = getMediaTitle();
episodeTransitionDebounce = null;
}, 2000);
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
// EC-12: a new episode dissolves any solo/desync state — the guest rejoins
// the room for the fresh content rather than staying stuck on the old one.
if (hcmDesynced) hcmReset();
const current = video ? getSyncCurrentTime(video) : null;
// Only trigger if: we had a previous title, the title changed,
// a video exists, and we're near the start of new content.
if (lastKnownMediaTitle && currentTitle
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
&& extractEpisodeId(currentTitle) !== null
// Do NOT pause here. We notify background.js first.
// Background checks the setting; if enabled it creates a lobby
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
runtimeMessage({
type: 'EPISODE_CHANGED',
payload: { newTitle }
}).catch(() => {});
}
onEpisodeTransition(currentTitle);
}
// Always track the latest known title
if (currentTitle) lastKnownMediaTitle = currentTitle;
function checkAndReportLobbyReady(expectedTitle) {
const video = findVideo();
const currentTitle = getMediaTitle();
const current = video ? getSyncCurrentTime(video) : null;
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& current !== null && video.readyState >= 1) {
if (current >= 5) {
expectedSeekTime = 0;
video.currentTime = 0;
}
// Match! Pause at start and report ready.
if (!video.paused) {
_setSuppress('paused');
video.pause();
@@ -1475,8 +1550,9 @@
}
video.pause();
function stopLobbyPoll() {
_pendingLobbyTitle = null;
@@ -1492,9 +1568,10 @@
}
return true;
}
function getPlayerActionFixes() {
return [
{
name: 'youtube-player-buttons',
urls: ['youtube.com'],
playPauseButtonSelector: '.ytp-play-button'
@@ -1509,8 +1586,9 @@
function getActivePlayerActionFix() {
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
// NOTE: Do NOT pause here. Three callers reach this function:
}
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
const button = document.querySelector(fix.playPauseButtonSelector);
if (!button) return false;
@@ -1532,8 +1610,9 @@
if (!video) return;
lobbyPollTimer = setInterval(() => {
if (action === EVENTS.SEEK) {
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
if (!Number.isFinite(target)) {
@@ -1552,12 +1631,13 @@
const actionFix = getActivePlayerActionFix();
if (tryPlayerActionFix(actionFix, action, video, data)) {
return;
lobbyPollTimer = null;
}
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
_setSuppress('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
@@ -1579,18 +1659,22 @@
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
const button = document.querySelector(fix.playPauseButtonSelector);
if (!button) return false;
}
}
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
function pollSeekReady(targetTime, timeoutMs = 8000) {
button.click();
}
if (action === EVENTS.SEEK) {
seekVideo(video, data.targetTime, data.delta);
return new Promise((resolve) => {
const interval = 150;
let elapsed = 0;
const timer = setInterval(() => {
if (destroyed) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
return;
@@ -1599,30 +1683,104 @@
if (!video) {
clearInterval(timer);
seekPollTimers.delete(timer);
if (action === EVENTS.SEEK) {
resolve(false);
return;
}
if (!Number.isFinite(target)) {
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
elapsed += interval;
const current = getSyncCurrentTime(video);
const timeDiff = current !== null ? Math.abs(current - targetTime) : Infinity;
const ready = video.readyState >= 3 && timeDiff < 2.0;
if (ready) {
}
data = { ...data, targetTime: target };
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(true);
} else if (elapsed >= timeoutMs) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
}
}, interval);
seekPollTimers.add(timer);
});
}
// Listen for commands from background.js
function handleRuntimeMessage(message, sender, sendResponse) {
if (!message) return;
if (message.type === 'TARGET_DEACTIVATE') {
destroyContentScript();
sendResponse({ ok: true });
return true;
}
if (destroyed) return;
if (message.action === 'get_current_time') {
const video = findVideo();
sendResponse({ currentTime: video ? getSyncCurrentTime(video) : null });
return true;
}
if (message.action === 'APPLY_AUDIO_SETTINGS') {
_audioProcessingAllowed = true;
_audioSettings = mergeAudioSettings(message.settings);
const video = findVideo();
if (video) applyAudioSettings(video, _audioSettings);
sendResponse({ ok: true });
return true;
}
if (message.action === 'RESET_AUDIO_PROCESSING') {
_audioProcessingAllowed = false;
bypassCurrentAudioProcessing();
sendResponse({ ok: true });
return true;
}
// Host Control Mode: room mode/role changed.
if (message.type === 'CONTROL_MODE') {
const wasGated = hcmIsGuestGated();
const prevHostPeerId = hcmHostPeerId;
hcmControlMode = message.controlMode || 'everyone';
hcmAmController = !!message.amController;
hcmHostPeerId = message.hostPeerId || null;
// Reset guest-side state when leaving the gated state, OR when the
// host identity changes (room switch, host-leave fallback, missed
try {
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
return;
}
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
sendResponse({ ok: true });
@@ -1631,8 +1789,8 @@
}
reportLog(`Playback prevented: ${e.message}`, 'warn');
// Host Control Mode: background blocked our local action — handle UX locally.
if (message.type === 'HOST_BLOCKED') {
@@ -1647,8 +1805,8 @@
// Background asks for an immediate state push (e.g. the first peer just
} catch (e) {
// joined while we were solo) so the newcomer syncs without waiting.
if (message.type === 'REQUEST_HEARTBEAT') {
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Lege fest, ob der Chat als schwebende Blase oder geöffnetes Overlay startet.",
"OPTION_CHAT_START_BUBBLE": "Schwebende Chatblase",
"OPTION_CHAT_START_OPEN": "Geöffnetes Overlay",
"LABEL_CHAT_REACTION_DISPLAY": "Schnellreaktionen",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Lege fest, ob Schnellreaktionen nur im Chat oder zusätzlich über dem Video erscheinen.",
"OPTION_CHAT_REACTIONS_CHAT": "Nur im Chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat und Video",
"CHAT_QUICK_REACTIONS": "Schnellreaktionen",
"CHAT_TITLE": "Raum-Chat",
"CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.",
"CHAT_OPEN": "Raum-Chat öffnen",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Nachricht schreiben...",
"CHAT_SEND": "Senden",
"CHAT_MISSING_KEY": "Chat ist nur über einen aktuellen Einladungslink verfügbar.",
"CHAT_TOO_LONG": "Maximal 500 Zeichen.",
"CHAT_TOO_LONG": "Maximal 5000 Zeichen.",
"CHAT_SEND_FAILED": "Nachricht konnte nicht gesendet werden.",
"CHAT_EMPTY": "Noch keine Nachrichten. Der Chat ist nur live.",
"LABEL_CHAT_NOTIFICATIONS": "Chat-Benachrichtigungen",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Choose whether chat starts as a floating bubble or an open overlay.",
"OPTION_CHAT_START_BUBBLE": "Floating bubble",
"OPTION_CHAT_START_OPEN": "Open overlay",
"LABEL_CHAT_REACTION_DISPLAY": "Quick reactions",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Choose whether quick reactions stay in chat or also fall over the video.",
"OPTION_CHAT_REACTIONS_CHAT": "Chat only",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat and video",
"CHAT_QUICK_REACTIONS": "Quick reactions",
"CHAT_TITLE": "Room Chat",
"CHAT_LIVE_ONLY": "Live only. No history.",
"CHAT_OPEN": "Open room chat",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Write a message...",
"CHAT_SEND": "Send",
"CHAT_MISSING_KEY": "Chat is available only through a current invite link.",
"CHAT_TOO_LONG": "Maximum 500 characters.",
"CHAT_TOO_LONG": "Maximum 5000 characters.",
"CHAT_SEND_FAILED": "Message could not be sent.",
"CHAT_EMPTY": "No messages yet. Chat is live only.",
"LABEL_CHAT_NOTIFICATIONS": "Chat Notifications",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Elige si el chat comienza como una burbuja flotante o una superposición abierta.",
"OPTION_CHAT_START_BUBBLE": "Burbuja flotante",
"OPTION_CHAT_START_OPEN": "Superposición abierta",
"LABEL_CHAT_REACTION_DISPLAY": "Reacciones rápidas",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Elige si las reacciones rápidas aparecen solo en el chat o también sobre el vídeo.",
"OPTION_CHAT_REACTIONS_CHAT": "Solo chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat y vídeo",
"CHAT_QUICK_REACTIONS": "Reacciones rápidas",
"CHAT_TITLE": "Chat de la sala",
"CHAT_LIVE_ONLY": "Solo en directo. Sin historial.",
"CHAT_OPEN": "Abrir chat de la sala",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Escribe un mensaje...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "El chat solo está disponible mediante un enlace de invitación actual.",
"CHAT_TOO_LONG": "Máximo 500 caracteres.",
"CHAT_TOO_LONG": "Máximo 5000 caracteres.",
"CHAT_SEND_FAILED": "No se pudo enviar el mensaje.",
"CHAT_EMPTY": "Aún no hay mensajes. El chat solo es en directo.",
"LABEL_CHAT_NOTIFICATIONS": "Notificaciones del chat",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Choisissez si le chat démarre sous forme de bulle flottante ou de fenêtre ouverte.",
"OPTION_CHAT_START_BUBBLE": "Bulle flottante",
"OPTION_CHAT_START_OPEN": "Fenêtre ouverte",
"LABEL_CHAT_REACTION_DISPLAY": "Réactions rapides",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Choisissez si les réactions rapides restent dans le chat ou tombent aussi sur la vidéo.",
"OPTION_CHAT_REACTIONS_CHAT": "Chat uniquement",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat et vidéo",
"CHAT_QUICK_REACTIONS": "Réactions rapides",
"CHAT_TITLE": "Chat du salon",
"CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.",
"CHAT_OPEN": "Ouvrir le chat du salon",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Écrire un message...",
"CHAT_SEND": "Envoyer",
"CHAT_MISSING_KEY": "Le chat est disponible uniquement via un lien d'invitation actuel.",
"CHAT_TOO_LONG": "500 caractères maximum.",
"CHAT_TOO_LONG": "5000 caractères maximum.",
"CHAT_SEND_FAILED": "Le message n'a pas pu être envoyé.",
"CHAT_EMPTY": "Aucun message. Le chat est uniquement en direct.",
"LABEL_CHAT_NOTIFICATIONS": "Notifications du chat",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Scegli se la chat si avvia come bolla mobile o overlay aperto.",
"OPTION_CHAT_START_BUBBLE": "Bolla fluttuante",
"OPTION_CHAT_START_OPEN": "Overlay aperto",
"LABEL_CHAT_REACTION_DISPLAY": "Reazioni rapide",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Scegli se le reazioni rapide restano nella chat o cadono anche sul video.",
"OPTION_CHAT_REACTIONS_CHAT": "Solo chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat e video",
"CHAT_QUICK_REACTIONS": "Reazioni rapide",
"CHAT_TITLE": "Chat della stanza",
"CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
"CHAT_OPEN": "Apri la chat della stanza",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Scrivi un messaggio...",
"CHAT_SEND": "Invia",
"CHAT_MISSING_KEY": "La chat è disponibile solo tramite un link di invito attuale.",
"CHAT_TOO_LONG": "Massimo 500 caratteri.",
"CHAT_TOO_LONG": "Massimo 5000 caratteri.",
"CHAT_SEND_FAILED": "Impossibile inviare il messaggio.",
"CHAT_EMPTY": "Nessun messaggio. La chat è solo in diretta.",
"LABEL_CHAT_NOTIFICATIONS": "Notifiche chat",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "フローティングバブルまたは開いたオーバーレイで開始するか選択します。",
"OPTION_CHAT_START_BUBBLE": "フローティングバブル",
"OPTION_CHAT_START_OPEN": "開いたオーバーレイ",
"LABEL_CHAT_REACTION_DISPLAY": "クイックリアクション",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "クイックリアクションをチャットだけに表示するか、動画上にも降らせるかを選択します。",
"OPTION_CHAT_REACTIONS_CHAT": "チャットのみ",
"OPTION_CHAT_REACTIONS_VIDEO": "チャットと動画",
"CHAT_QUICK_REACTIONS": "クイックリアクション",
"CHAT_TITLE": "ルームチャット",
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
"CHAT_OPEN": "ルームチャットを開く",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "メッセージを入力...",
"CHAT_SEND": "送信",
"CHAT_MISSING_KEY": "チャットは最新の招待リンクからのみ利用できます。",
"CHAT_TOO_LONG": "最大500文字です。",
"CHAT_TOO_LONG": "最大5000文字です。",
"CHAT_SEND_FAILED": "メッセージを送信できませんでした。",
"CHAT_EMPTY": "まだメッセージはありません。チャットはライブのみです。",
"LABEL_CHAT_NOTIFICATIONS": "チャット通知",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "플로팅 버블 또는 열린 오버레이로 시작할지 선택합니다.",
"OPTION_CHAT_START_BUBBLE": "플로팅 채팅 버블",
"OPTION_CHAT_START_OPEN": "열린 오버레이",
"LABEL_CHAT_REACTION_DISPLAY": "빠른 반응",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "빠른 반응을 채팅에만 표시하거나 동영상 위에도 떨어지게 할지 선택합니다.",
"OPTION_CHAT_REACTIONS_CHAT": "채팅만",
"OPTION_CHAT_REACTIONS_VIDEO": "채팅 및 동영상",
"CHAT_QUICK_REACTIONS": "빠른 반응",
"CHAT_TITLE": "방 채팅",
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
"CHAT_OPEN": "방 채팅 열기",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "메시지를 입력하세요...",
"CHAT_SEND": "보내기",
"CHAT_MISSING_KEY": "채팅은 최신 초대 링크를 통해서만 사용할 수 있습니다.",
"CHAT_TOO_LONG": "최대 500자입니다.",
"CHAT_TOO_LONG": "최대 5000자입니다.",
"CHAT_SEND_FAILED": "메시지를 보낼 수 없습니다.",
"CHAT_EMPTY": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다.",
"LABEL_CHAT_NOTIFICATIONS": "채팅 알림",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Kies of de chat start als zwevende knop of als geopende overlay.",
"OPTION_CHAT_START_BUBBLE": "Zwevende chatknop",
"OPTION_CHAT_START_OPEN": "Geopende overlay",
"LABEL_CHAT_REACTION_DISPLAY": "Snelle reacties",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Kies of snelle reacties alleen in de chat of ook over de video verschijnen.",
"OPTION_CHAT_REACTIONS_CHAT": "Alleen chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat en video",
"CHAT_QUICK_REACTIONS": "Snelle reacties",
"CHAT_TITLE": "Kamerchat",
"CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
"CHAT_OPEN": "Kamerchat openen",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Schrijf een bericht...",
"CHAT_SEND": "Verzenden",
"CHAT_MISSING_KEY": "Chat is alleen beschikbaar via een actuele uitnodigingslink.",
"CHAT_TOO_LONG": "Maximaal 500 tekens.",
"CHAT_TOO_LONG": "Maximaal 5000 tekens.",
"CHAT_SEND_FAILED": "Bericht kon niet worden verzonden.",
"CHAT_EMPTY": "Nog geen berichten. Chat is alleen live.",
"LABEL_CHAT_NOTIFICATIONS": "Chatmeldingen",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Wybierz, czy czat ma startować jako pływający przycisk, czy otwarta nakładka.",
"OPTION_CHAT_START_BUBBLE": "Pływający przycisk",
"OPTION_CHAT_START_OPEN": "Otwarta nakładka",
"LABEL_CHAT_REACTION_DISPLAY": "Szybkie reakcje",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Wybierz, czy szybkie reakcje mają być tylko na czacie, czy również spadać na wideo.",
"OPTION_CHAT_REACTIONS_CHAT": "Tylko czat",
"OPTION_CHAT_REACTIONS_VIDEO": "Czat i wideo",
"CHAT_QUICK_REACTIONS": "Szybkie reakcje",
"CHAT_TITLE": "Czat pokoju",
"CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.",
"CHAT_OPEN": "Otwórz czat pokoju",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Napisz wiadomość...",
"CHAT_SEND": "Wyślij",
"CHAT_MISSING_KEY": "Czat jest dostępny tylko przez aktualny link zaproszenia.",
"CHAT_TOO_LONG": "Maksymalnie 500 znaków.",
"CHAT_TOO_LONG": "Maksymalnie 5000 znaków.",
"CHAT_SEND_FAILED": "Nie udało się wysłać wiadomości.",
"CHAT_EMPTY": "Brak wiadomości. Czat działa tylko na żywo.",
"LABEL_CHAT_NOTIFICATIONS": "Powiadomienia czatu",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
"LABEL_CHAT_REACTION_DISPLAY": "Reações rápidas",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Escolha se as reações rápidas ficam apenas no chat ou também caem sobre o vídeo.",
"OPTION_CHAT_REACTIONS_CHAT": "Somente chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat e vídeo",
"CHAT_QUICK_REACTIONS": "Reações rápidas",
"CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Escreva uma mensagem...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "O chat está disponível apenas por um link de convite atual.",
"CHAT_TOO_LONG": "Máximo de 500 caracteres.",
"CHAT_TOO_LONG": "Máximo de 5000 caracteres.",
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
"CHAT_EMPTY": "Ainda não há mensagens. O chat é somente ao vivo.",
"LABEL_CHAT_NOTIFICATIONS": "Notificações do chat",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
"LABEL_CHAT_REACTION_DISPLAY": "Reações rápidas",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Escolha se as reações rápidas ficam apenas no chat ou também caem sobre o vídeo.",
"OPTION_CHAT_REACTIONS_CHAT": "Apenas chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat e vídeo",
"CHAT_QUICK_REACTIONS": "Reações rápidas",
"CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Escreva uma mensagem...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "O chat está disponível apenas através de um link de convite atual.",
"CHAT_TOO_LONG": "Máximo de 500 caracteres.",
"CHAT_TOO_LONG": "Máximo de 5000 caracteres.",
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
"CHAT_EMPTY": "Ainda não há mensagens. O chat funciona apenas em direto.",
"LABEL_CHAT_NOTIFICATIONS": "Notificações do chat",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Выберите, запускать чат как плавающую кнопку или открытое окно.",
"OPTION_CHAT_START_BUBBLE": "Плавающая кнопка",
"OPTION_CHAT_START_OPEN": "Открытое окно",
"LABEL_CHAT_REACTION_DISPLAY": "Быстрые реакции",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Выберите, показывать быстрые реакции только в чате или также поверх видео.",
"OPTION_CHAT_REACTIONS_CHAT": "Только чат",
"OPTION_CHAT_REACTIONS_VIDEO": "Чат и видео",
"CHAT_QUICK_REACTIONS": "Быстрые реакции",
"CHAT_TITLE": "Чат комнаты",
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
"CHAT_OPEN": "Открыть чат комнаты",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Напишите сообщение...",
"CHAT_SEND": "Отправить",
"CHAT_MISSING_KEY": "Чат доступен только по актуальной ссылке-приглашению.",
"CHAT_TOO_LONG": "Не более 500 символов.",
"CHAT_TOO_LONG": "Не более 5000 символов.",
"CHAT_SEND_FAILED": "Не удалось отправить сообщение.",
"CHAT_EMPTY": "Сообщений пока нет. Чат работает только в реальном времени.",
"LABEL_CHAT_NOTIFICATIONS": "Уведомления чата",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Sohbetin kayan balon veya açık kaplama olarak başlamasını seçin.",
"OPTION_CHAT_START_BUBBLE": "Kayan sohbet balonu",
"OPTION_CHAT_START_OPEN": "Açık kaplama",
"LABEL_CHAT_REACTION_DISPLAY": "Hızlı tepkiler",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Hızlı tepkilerin yalnızca sohbette mi yoksa videonun üzerinde de mi görüneceğini seçin.",
"OPTION_CHAT_REACTIONS_CHAT": "Yalnızca sohbet",
"OPTION_CHAT_REACTIONS_VIDEO": "Sohbet ve video",
"CHAT_QUICK_REACTIONS": "Hızlı tepkiler",
"CHAT_TITLE": "Oda Sohbeti",
"CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
"CHAT_OPEN": "Oda sohbetini aç",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Bir mesaj yazın...",
"CHAT_SEND": "Gönder",
"CHAT_MISSING_KEY": "Sohbet yalnızca güncel bir davet bağlantısıyla kullanılabilir.",
"CHAT_TOO_LONG": "En fazla 500 karakter.",
"CHAT_TOO_LONG": "En fazla 5000 karakter.",
"CHAT_SEND_FAILED": "Mesaj gönderilemedi.",
"CHAT_EMPTY": "Henüz mesaj yok. Sohbet yalnızca canlıdır.",
"LABEL_CHAT_NOTIFICATIONS": "Sohbet bildirimleri",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "Виберіть, запускати чат як плаваючу кнопку чи відкрите вікно.",
"OPTION_CHAT_START_BUBBLE": "Плаваюча кнопка",
"OPTION_CHAT_START_OPEN": "Відкрите вікно",
"LABEL_CHAT_REACTION_DISPLAY": "Швидкі реакції",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Виберіть, показувати швидкі реакції лише в чаті чи також поверх відео.",
"OPTION_CHAT_REACTIONS_CHAT": "Лише чат",
"OPTION_CHAT_REACTIONS_VIDEO": "Чат і відео",
"CHAT_QUICK_REACTIONS": "Швидкі реакції",
"CHAT_TITLE": "Чат кімнати",
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
"CHAT_OPEN": "Відкрити чат кімнати",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "Напишіть повідомлення...",
"CHAT_SEND": "Надіслати",
"CHAT_MISSING_KEY": "Чат доступний лише за актуальним посиланням-запрошенням.",
"CHAT_TOO_LONG": "Не більше 500 символів.",
"CHAT_TOO_LONG": "Не більше 5000 символів.",
"CHAT_SEND_FAILED": "Не вдалося надіслати повідомлення.",
"CHAT_EMPTY": "Повідомлень ще немає. Чат працює лише наживо.",
"LABEL_CHAT_NOTIFICATIONS": "Сповіщення чату",
+6 -1
View File
@@ -262,6 +262,11 @@
"LABEL_CHAT_START_MODE_TOOLTIP": "选择以悬浮气泡或打开的浮层启动聊天。",
"OPTION_CHAT_START_BUBBLE": "悬浮聊天气泡",
"OPTION_CHAT_START_OPEN": "打开浮层",
"LABEL_CHAT_REACTION_DISPLAY": "快捷回应",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "选择快捷回应仅显示在聊天中,或同时飘落在视频上。",
"OPTION_CHAT_REACTIONS_CHAT": "仅聊天",
"OPTION_CHAT_REACTIONS_VIDEO": "聊天和视频",
"CHAT_QUICK_REACTIONS": "快捷回应",
"CHAT_TITLE": "房间聊天",
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
"CHAT_OPEN": "打开房间聊天",
@@ -272,7 +277,7 @@
"CHAT_PLACEHOLDER": "输入消息...",
"CHAT_SEND": "发送",
"CHAT_MISSING_KEY": "聊天仅可通过当前邀请链接使用。",
"CHAT_TOO_LONG": "最多500个字符。",
"CHAT_TOO_LONG": "最多5000个字符。",
"CHAT_SEND_FAILED": "无法发送消息。",
"CHAT_EMPTY": "暂无消息。聊天仅实时显示。",
"LABEL_CHAT_NOTIFICATIONS": "聊天通知",
+1 -1
View File
@@ -3,7 +3,7 @@
"default_locale": "en",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "3.0.0",
"version": "3.0.1",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
+9
View File
@@ -1669,6 +1669,15 @@
</select>
</span>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatReactionDisplay" title="Choose whether quick reactions stay in chat or also fall over the video." data-i18n="LABEL_CHAT_REACTION_DISPLAY" data-i18n-title="LABEL_CHAT_REACTION_DISPLAY_TOOLTIP">Quick reactions</label>
<span class="settings-select">
<select id="chatReactionDisplay" class="settings-control">
<option value="chat" data-i18n="OPTION_CHAT_REACTIONS_CHAT">Chat only</option>
<option value="video" data-i18n="OPTION_CHAT_REACTIONS_VIDEO">Chat and video</option>
</select>
</span>
</div>
</div>
</details>
+9 -2
View File
@@ -73,6 +73,7 @@ const elements = {
chatPosition: document.getElementById('chatPosition'),
chatSize: document.getElementById('chatSize'),
chatStartMode: document.getElementById('chatStartMode'),
chatReactionDisplay: document.getElementById('chatReactionDisplay'),
sendTabTitle: document.getElementById('sendTabTitle'),
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
@@ -342,7 +343,7 @@ function setRoomRefreshCooldown() {
async function init() {
// Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there).
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -378,6 +379,7 @@ async function init() {
if (elements.chatPosition) elements.chatPosition.value = ['left', 'detached'].includes(localData.chatPosition) ? localData.chatPosition : 'right';
if (elements.chatSize) elements.chatSize.value = ['compact', 'large', 'custom'].includes(localData.chatSize) ? localData.chatSize : 'standard';
if (elements.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble';
if (elements.chatReactionDisplay) elements.chatReactionDisplay.value = localData.chatReactionDisplay === 'video' ? 'video' : 'chat';
syncChatSettingsState();
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
@@ -1469,7 +1471,7 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
function syncChatSettingsState() {
const enabled = elements.chatEnabled?.checked === true;
for (const control of [elements.chatNotifications, elements.chatPosition, elements.chatSize, elements.chatStartMode]) {
for (const control of [elements.chatNotifications, elements.chatPosition, elements.chatSize, elements.chatStartMode, elements.chatReactionDisplay]) {
if (control) control.disabled = !enabled;
}
document.querySelectorAll('[data-chat-setting]').forEach(row => {
@@ -1506,6 +1508,11 @@ if (elements.chatStartMode) {
chrome.storage.local.set({ chatStartMode: elements.chatStartMode.value === 'open' ? 'open' : 'bubble' });
});
}
if (elements.chatReactionDisplay) {
elements.chatReactionDisplay.addEventListener('change', () => {
chrome.storage.local.set({ chatReactionDisplay: elements.chatReactionDisplay.value === 'video' ? 'video' : 'chat' });
});
}
if (elements.sendTabTitle) {
elements.sendTabTitle.addEventListener('change', () => {
+70
View File
@@ -0,0 +1,70 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
expect(backgroundSource).not.toContain('chrome.tabs.onActivated');
expect(backgroundSource).not.toContain('chrome.tabs.query({})');
expect(backgroundSource).toContain("target: { tabId }");
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
expect(backgroundSource).toContain("chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' })");
const activationStart = backgroundSource.indexOf('async function activateTargetTab');
const activationEnd = backgroundSource.indexOf('async function reactivateCurrentTarget', activationStart);
const activationSource = backgroundSource.slice(activationStart, activationEnd);
expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)'))
.toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId'));
expect(activationSource).toContain('previousTabId !== selectedTabId');
expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)');
expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)');
});
it('fully deactivates old and superseded target injections', () => {
expect(backgroundSource).toContain("chrome.tabs.sendMessage(normalizedTabId, { type: 'TARGET_DEACTIVATE' })");
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId\)/g)?.length).toBeGreaterThanOrEqual(4);
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
it('tears down every persistent content-script resource', () => {
expect(contentSource).toContain('function destroyContentScript()');
expect(contentSource).toContain('observer.disconnect()');
expect(contentSource).toContain('keepAlivePort.disconnect()');
expect(contentSource).toContain('for (const video of attachedVideos)');
expect(contentSource).toContain("document.removeEventListener('visibilitychange', handleVisibilityChange)");
expect(contentSource).toContain("window.removeEventListener('pagehide', handlePageHide)");
expect(contentSource).toContain("window.removeEventListener('pageshow', handlePageShow)");
expect(contentSource).toContain('chrome.storage.onChanged.removeListener(handleStorageChanged)');
expect(contentSource).toContain('chrome.runtime.onMessage.removeListener(handleRuntimeMessage)');
expect(contentSource).toContain('window.koalaSyncInjected = false');
});
it('stops the MAIN-world seek bridge when its target is deactivated', () => {
expect(backgroundSource).toContain('const timelineInterval = setInterval');
expect(backgroundSource).toContain('clearInterval(timelineInterval)');
expect(backgroundSource).toContain("window.removeEventListener('message', handleBridgeMessage)");
expect(contentSource).toContain("kind: 'destroy'");
});
it('self-cleans instead of throwing when an extension reload invalidates the context', () => {
expect(contentSource).toContain('if (!chrome.runtime?.id)');
expect(contentSource).toContain('destroyContentScript()');
expect(contentSource).toContain('return chrome.runtime.sendMessage(message, callback) || Promise.resolve(undefined)');
expect(contentSource).toContain('function handleVisibilityChange()');
});
it('suspends background work for bfcache and restores it without duplicate injection', () => {
expect(contentSource).toContain('pageSuspended = true');
expect(contentSource).toContain('if (destroyed || pageSuspended) return');
expect(contentSource).toContain('if (!destroyed && !pageSuspended) scheduleLifecycleTimeout(connectKeepAlivePort, 1000)');
expect(contentSource).toMatch(/function handlePageShow\(event\)[\s\S]*if \(!event\.persisted\) return;[\s\S]*pageSuspended = false/);
expect(contentSource).toMatch(/function handlePageShow\(event\)[\s\S]*observer\.observe\(document\.documentElement[\s\S]*setupListeners\(\)[\s\S]*connectKeepAlivePort\(\)/);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "3.0.0",
"version": "3.0.1",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
+2 -2
View File
@@ -95,7 +95,7 @@ function replaceRequiredBlock(content, pattern, replacement, description) {
return content.replace(pattern, replacement);
}
// Helper to copy files, ignoring manifest.json and manifest.base.json
// Helper to copy runtime files, ignoring source manifests and test-only modules
// Also injects shared constants into content.js
function copyExtensionFiles(targetDir, browserName) {
fs.mkdirSync(targetDir, { recursive: true });
@@ -120,7 +120,7 @@ function copyExtensionFiles(targetDir, browserName) {
const items = fs.readdirSync(extDir);
for (const item of items) {
if (item === 'manifest.json' || item === 'manifest.base.json') continue;
if (item === 'manifest.json' || item === 'manifest.base.json' || /\.test\.[cm]?js$/u.test(item)) continue;
const srcPath = path.join(extDir, item);
const destPath = path.join(targetDir, item);
+7 -3
View File
@@ -23,7 +23,7 @@ function detailsSection(marker) {
const chatSection = detailsSection('data-i18n="CHAT_TITLE"');
const syncSection = detailsSection('data-i18n="LABEL_SETTINGS_GROUP_SYNC"');
for (const id of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode']) {
for (const id of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay']) {
assert.match(chatSection, new RegExp(`id="${id}"`), `${id} belongs in the dedicated chat settings section`);
}
assert.doesNotMatch(syncSection, /id="chatEnabled"/, 'chat enablement must not remain under Playback & Sync');
@@ -37,12 +37,15 @@ for (const value of ['compact', 'standard', 'large', 'custom']) {
for (const value of ['bubble', 'open']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat start mode supports ${value}`);
}
for (const value of ['chat', 'video']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat reaction display supports ${value}`);
}
for (const key of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode']) {
for (const key of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay']) {
assert.match(popupJs, new RegExp(`chrome\\.storage\\.local\\.set\\(\\{ ${key}`), `popup persists ${key}`);
assert.ok(backgroundJs.includes(`'${key}'`), `legacy sync purge includes ${key}`);
}
for (const key of ['chatPosition', 'chatSize', 'chatStartMode']) {
for (const key of ['chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay']) {
assert.ok(overlayJs.includes(`'${key}'`), `overlay reads ${key}`);
}
assert.match(backgroundJs, /chatEnabled:\s*data\.chatEnabled === true/, 'background reads chatEnabled with an off-by-default contract');
@@ -61,5 +64,6 @@ assert.match(overlayJs, /layout\.customWidth = rect\.width[\s\S]*layout\.customH
assert.match(overlayJs, /changes\.chatPosition[\s\S]*setMode/, 'position changes apply live');
assert.match(overlayJs, /changes\.chatSize[\s\S]*setSize/, 'size changes apply live');
assert.match(overlayJs, /changes\.chatStartMode[\s\S]*setOpened/, 'startup-mode changes apply live');
assert.match(overlayJs, /changes\.chatReactionDisplay[\s\S]*chatReactionDisplay/, 'reaction display changes apply live');
console.log('chat settings tests passed');
+27 -2
View File
@@ -12,6 +12,7 @@ const demoCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'demo.c
const landingPrimaryCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'landing-primary.css'), 'utf8');
const legalCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'legal.css'), 'utf8');
const joinPage = fs.readFileSync(path.join(repoRoot, 'website', 'join.html'), 'utf8');
const helpPage = fs.readFileSync(path.join(repoRoot, 'website', 'help.html'), 'utf8');
const siteAccessHelpPage = fs.readFileSync(path.join(repoRoot, 'website', 'site-access-help.html'), 'utf8');
const websiteBuild = fs.readFileSync(path.join(repoRoot, 'website', 'build.cjs'), 'utf8');
const imprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'imprint.html'), 'utf8');
@@ -101,6 +102,7 @@ if (!llmsText.includes(`Current website release: ${websiteVersion}`)) {
throw new Error(`llms.txt release must match website/version.json (${websiteVersion})`);
}
const requiredSupportSocialMetadata = [
'<link rel="canonical" href="https://sync.koalastuff.net/site-access-help">',
'name="twitter:card" content="summary_large_image"',
'name="twitter:title"',
'name="twitter:description"',
@@ -114,8 +116,29 @@ for (const metadata of requiredSupportSocialMetadata) {
throw new Error(`site-access-help.html is missing SEO metadata: ${metadata}`);
}
}
const requiredHelpMetadata = [
'<link rel="canonical" href="https://sync.koalastuff.net/help">',
'type="text/markdown" href="https://sync.koalastuff.net/llms.txt"',
'name="twitter:card" content="summary_large_image"',
'property="og:image:alt"',
'name="twitter:image:alt"',
'"@type": ["CollectionPage", "FAQPage"]',
'"@type": "BreadcrumbList"',
'"datePublished"',
'"dateModified"'
];
for (const metadata of requiredHelpMetadata) {
if (!helpPage.includes(metadata)) {
throw new Error(`help.html is missing SEO/AEO metadata: ${metadata}`);
}
}
if (!llmsText.includes('[Help Center](https://sync.koalastuff.net/help)')
|| !llmsText.includes('[Website access guide](https://sync.koalastuff.net/site-access-help)')) {
throw new Error('llms.txt must expose the Help Center and website-access guide');
}
if (!websiteBuild.includes("['log', '-1', '--format=%cs', '--', ...sourceFiles]")
|| !websiteBuild.includes("lastmod(['website/site-access-help.html'])")
|| !websiteBuild.includes("lastmod(['website/help.html', 'website/styles/support.css', 'website/app.js'])")
|| !websiteBuild.includes("lastmod(['website/site-access-help.html', 'website/app.js'])")
|| !websiteBuild.includes("['rev-parse', '--is-shallow-repository']")
|| !websiteBuild.includes("['diff', '--name-only', '--']")
|| !websiteBuild.includes("['diff', '--cached', '--name-only', '--']")
@@ -123,6 +146,9 @@ if (!websiteBuild.includes("['log', '-1', '--format=%cs', '--', ...sourceFiles]"
|| !websiteBuild.includes('sourceFiles.some(file => dirtySourceFiles.has(file))')) {
throw new Error('Sitemap lastmod values must come from the mapped source files in Git');
}
if (websiteBuild.includes('<changefreq>') || websiteBuild.includes('<priority>')) {
throw new Error('Sitemap must not emit ignored changefreq or priority hints');
}
const documentedRelayUrls = [...llmsText.matchAll(/`(wss:\/\/[^`\s]+)`/g)].map(([, value]) => new URL(value));
const hasCanonicalPublicRelay = documentedRelayUrls.some((url) => (
url.protocol === 'wss:' &&
@@ -157,7 +183,6 @@ for (const value of requiredSelfHostingValues) {
if ((selfHostingExamples.match(/localhost:3000/g) || []).length !== 2 || selfHostingExamples.includes('KoalaSync:3000')) {
throw new Error('Both Caddy examples must match the compose loopback port binding');
}
const landingStylesheet = landingStylesheets[0][0];
if (!/href="\{\{ASSET_PATH\}\}landing\.min\.css"/.test(landingStylesheet)) {
throw new Error('Landing stylesheet must use the landing.min.css build placeholder');
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "3.0.0";
export const APP_VERSION = "3.0.1";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
+65 -14
View File
@@ -235,20 +235,55 @@ document.addEventListener('DOMContentLoaded', () => {
// Auto-update URL hash as user scrolls through sections
// (preserves position across language switches)
if ('IntersectionObserver' in window) {
const sectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
if (entry.target.id === 'top') {
history.replaceState(null, null, window.location.pathname + window.location.search);
} else {
history.replaceState(null, null, '#' + entry.target.id);
}
if (forestGreet) forestGreet();
}
const trackedSections = [...document.querySelectorAll('section[id], header[id]')];
if (trackedSections.length > 0) {
let hashUpdateFrame = null;
let hashNavigationTimer = null;
let hashNavigationInProgress = false;
const updateSectionHash = () => {
hashUpdateFrame = null;
const readingLine = 112;
const activeSection = trackedSections.reduce((closest, section) => {
const distance = Math.abs(section.getBoundingClientRect().top - readingLine);
const closestDistance = Math.abs(closest.getBoundingClientRect().top - readingLine);
return distance < closestDistance ? section : closest;
});
}, { threshold: 0.3 });
document.querySelectorAll('section[id], header[id]').forEach(el => sectionObserver.observe(el));
const nextHash = activeSection.id === 'top' ? '' : '#' + activeSection.id;
if (window.location.hash === nextHash) return;
if (nextHash === '') {
history.replaceState(null, null, window.location.pathname + window.location.search);
} else {
history.replaceState(null, null, nextHash);
}
if (forestGreet) forestGreet();
};
const scheduleSectionHashUpdate = () => {
if (hashUpdateFrame !== null) return;
hashUpdateFrame = requestAnimationFrame(updateSectionHash);
};
const finishHashNavigation = () => {
hashNavigationTimer = null;
hashNavigationInProgress = false;
scheduleSectionHashUpdate();
};
const beginHashNavigation = () => {
hashNavigationInProgress = true;
if (hashNavigationTimer !== null) clearTimeout(hashNavigationTimer);
hashNavigationTimer = setTimeout(finishHashNavigation, 250);
};
const handleSectionScroll = () => {
if (!hashNavigationInProgress) {
scheduleSectionHashUpdate();
return;
}
if (hashNavigationTimer !== null) clearTimeout(hashNavigationTimer);
hashNavigationTimer = setTimeout(finishHashNavigation, 180);
};
window.addEventListener('hashchange', beginHashNavigation);
window.addEventListener('scroll', handleSectionScroll, { passive: true });
if (window.location.hash) beginHashNavigation();
else scheduleSectionHashUpdate();
}
// Navbar scroll effect (class-based so it follows the active theme)
@@ -1558,6 +1593,8 @@ document.addEventListener('DOMContentLoaded', () => {
tempSpan.style.fontFamily = style.fontFamily;
tempSpan.style.fontSize = style.fontSize;
tempSpan.style.fontWeight = style.fontWeight;
tempSpan.style.fontStyle = style.fontStyle;
tempSpan.style.letterSpacing = style.letterSpacing;
tempSpan.style.visibility = 'hidden';
tempSpan.style.position = 'absolute';
tempSpan.style.whiteSpace = 'nowrap';
@@ -1567,7 +1604,18 @@ document.addEventListener('DOMContentLoaded', () => {
tempSpan.textContent = activeOption.textContent;
document.body.appendChild(tempSpan);
const textWidth = tempSpan.getBoundingClientRect().width;
select.style.width = (textWidth + 18) + 'px';
const borderBoxChrome = style.boxSizing === 'border-box'
? [
style.paddingLeft,
style.paddingRight,
style.borderLeftWidth,
style.borderRightWidth
].reduce((total, value) => total + (Number.parseFloat(value) || 0), 0)
: 0;
// Leave a few extra pixels for platform-specific native select
// text rendering. The right padding already reserves the
// visible chevron area.
select.style.width = `${Math.ceil(textWidth + borderBoxChrome + 4)}px`;
document.body.removeChild(tempSpan);
}
});
@@ -1712,6 +1760,9 @@ document.addEventListener('DOMContentLoaded', () => {
updateDynamicVersion();
localizeHomeLinks();
initLanguageSelectorValue();
if (document.fonts?.ready) {
document.fonts.ready.then(adjustDropdownWidth);
}
// FAQ Accordion Transition (Web Animations API for smooth vertical spring height collapse/expand)
try {
document.querySelectorAll('.faq-item').forEach(details => {
+21 -14
View File
@@ -109,12 +109,22 @@ function stageFlagFontSubset(websiteDir, wwwDir) {
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (entry.name === '.DS_Store') continue;
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
entry.isDirectory() ? copyDirSync(s, d) : fs.copyFileSync(s, d);
}
}
function removeBuildMetadata(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.name === '.DS_Store') fs.rmSync(entryPath, { force: true });
else if (entry.isDirectory()) removeBuildMetadata(entryPath);
}
}
function sha8(buf) { return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8); }
function sha384(buf) { return 'sha384-' + crypto.createHash('sha384').update(buf).digest('base64'); }
@@ -164,6 +174,7 @@ async function compile() {
const websiteDir = __dirname;
const wwwDir = path.join(websiteDir, 'www');
fs.mkdirSync(wwwDir, { recursive: true });
removeBuildMetadata(wwwDir);
// ── 0. Auto-generate website logo sizes and sync favicons ──
console.log('Generating responsive website logos...');
@@ -524,6 +535,7 @@ async function compile() {
{ src: 'join.html', dest: 'join.html' },
{ src: 'imprint.html', dest: 'imprint.html' },
{ src: 'privacy.html', dest: 'privacy.html' },
{ src: 'help.html', dest: 'help.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' },
@@ -795,29 +807,24 @@ function generateSitemap(websiteDir, wwwDir) {
xml += `
<url>
<loc>https://sync.koalastuff.net/privacy</loc>${lastmod(['website/privacy.html'])}
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/site-access-help.html</loc>${lastmod(['website/site-access-help.html'])}
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<loc>https://sync.koalastuff.net/help</loc>${lastmod(['website/help.html', 'website/styles/support.css', 'website/app.js'])}
</url>
<url>
<loc>https://sync.koalastuff.net/site-access-help</loc>${lastmod(['website/site-access-help.html', 'website/app.js'])}
</url>
<url>
<loc>https://sync.koalastuff.net/de/datenschutz</loc>${lastmod(['website/datenschutz-de.html'])}
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>`;
function addPage(relativePath, changefreq, priority, templateSource) {
function addPage(relativePath, templateSource) {
for (const lang of languages) {
const loc = `https://sync.koalastuff.net/${lang.prefix}${relativePath}`;
const sourceFiles = [templateSource, `website/locales/${lang.code}.json`];
xml += `
<url>
<loc>${loc}</loc>${lastmod(sourceFiles)}
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>`;
<loc>${loc}</loc>${lastmod(sourceFiles)}`;
for (const alt of languages) {
const altHref = `https://sync.koalastuff.net/${alt.prefix}${relativePath}`;
xml += `
@@ -830,8 +837,8 @@ function generateSitemap(websiteDir, wwwDir) {
}
}
addPage('', 'weekly', '1.0', 'website/template.html');
addPage('alternatives', 'weekly', '0.7', 'website/alternatives/index.html');
addPage('', 'website/template.html');
addPage('alternatives', 'website/alternatives/index.html');
const subpages = [
['alternatives/teleparty', 'website/alternatives/teleparty.html'],
@@ -842,7 +849,7 @@ function generateSitemap(websiteDir, wwwDir) {
['alternatives/twoseven', 'website/alternatives/twoseven.html']
];
for (const [sub, templateSource] of subpages) {
addPage(sub, 'weekly', '0.7', templateSource);
addPage(sub, templateSource);
}
xml += `\n</urlset>\n`;
+457
View File
@@ -0,0 +1,457 @@
<!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 Help | Setup, Chat, Privacy & Troubleshooting</title>
<meta name="description" content="Get KoalaSync working, fix invite and chat problems, understand supported devices and data handling, and verify official open-source releases.">
<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/help">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/help">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/help">
<link rel="alternate" type="text/markdown" href="https://sync.koalastuff.net/llms.txt" title="KoalaSync information for AI assistants">
<meta property="og:type" content="website">
<meta property="og:url" content="https://sync.koalastuff.net/help">
<meta property="og:title" content="KoalaSync Help">
<meta property="og:description" content="Setup help, invite troubleshooting, encrypted chat, supported devices, privacy, open-source releases, and reproducible bug reports.">
<meta property="og:image" content="https://sync.koalastuff.net/assets/og-image.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:type" content="image/png">
<meta property="og:image:alt" content="KoalaSync browser extension">
<meta property="og:site_name" content="KoalaSync">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="KoalaSync Help">
<meta name="twitter:description" content="Setup help, invite troubleshooting, encrypted chat, supported devices, privacy, open-source releases, and reproducible bug reports.">
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/og-image.png">
<meta name="twitter:image:alt" content="KoalaSync browser extension">
<meta name="theme-color" content="#10190e">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://sync.koalastuff.net/#website",
"name": "KoalaSync",
"url": "https://sync.koalastuff.net/"
},
{
"@type": "Organization",
"@id": "https://sync.koalastuff.net/#organization",
"name": "KoalaSync",
"url": "https://sync.koalastuff.net/",
"logo": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
"sameAs": ["https://github.com/Shik3i/KoalaSync"]
},
{
"@type": ["CollectionPage", "FAQPage"],
"@id": "https://sync.koalastuff.net/help#webpage",
"name": "KoalaSync Help",
"headline": "KoalaSync Help: Setup, Chat, Privacy and Troubleshooting",
"description": "Help for joining rooms, granting browser access, fixing encrypted room chat and playback synchronization, understanding data handling, and verifying official releases.",
"url": "https://sync.koalastuff.net/help",
"datePublished": "2026-07-28",
"dateModified": "2026-07-28",
"inLanguage": "en",
"isPartOf": {"@id": "https://sync.koalastuff.net/#website"},
"publisher": {"@id": "https://sync.koalastuff.net/#organization"},
"mainEntity": [
{
"@type": "Question",
"name": "Why does the browser show such a broad permission warning for KoalaSync?",
"acceptedAnswer": {
"@type": "Answer",
"text": "KoalaSync works with video players across many websites. It uses access on the video tab you actively select to find and control the video element, synchronize playback, and display optional room chat. Open tab titles populate the tab selector; KoalaSync does not create a general browsing or watch history."
}
},
{
"@type": "Question",
"name": "Why does a KoalaSync invite link not join the room?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Open the complete invite link in the same desktop browser profile where KoalaSync is installed. If the invite page was already open when the extension was installed or enabled, reload it. Confirm that the browser allows KoalaSync to run on sync.koalastuff.net, then ask the sender for a newly copied invite if the link is incomplete."
}
},
{
"@type": "Question",
"name": "Why does a video tab not appear in KoalaSync?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Reload the video page, reopen KoalaSync, and disable Hide Clutter Tabs in Settings. If the tab then appears, report the domain because it was filtered by mistake. Otherwise, verify that KoalaSync has website access."
}
},
{
"@type": "Question",
"name": "Why does KoalaSync find no video on a listed tab?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Select the tab containing the visible player and start playback once. Check Video Debug Info in the Status tab, reload after permission changes, and temporarily disable extensions that modify the player. Some protected or non-HTML5 players may need site-specific support."
}
},
{
"@type": "Question",
"name": "Why does KoalaSync room chat not work for everyone?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Room chat requires KoalaSync 3.0.0 or newer. The room creator receives a chat encryption secret when creating a new room, and every friend must join through that room's current invite link to receive the same secret. Manual room entry and old invite links can still join playback synchronization, but do not provide chat access."
}
},
{
"@type": "Question",
"name": "Why are connected KoalaSync participants not synchronized?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Confirm that everyone uses the same KoalaSync version and selected the correct local video tab. Reload after website-access changes, test play, pause, and seeking separately, and collect a debug report from each affected participant."
}
},
{
"@type": "Question",
"name": "Does KoalaSync stream the video to other participants?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. KoalaSync does not stream, upload, proxy, share, or bypass access to video content. Everyone watches locally in their own browser and needs their own access to the website or streaming service. KoalaSync only synchronizes playback actions such as play, pause, and seeking."
}
},
{
"@type": "Question",
"name": "Which devices and browsers support KoalaSync?",
"acceptedAnswer": {
"@type": "Answer",
"text": "KoalaSync is a desktop browser extension for Chrome, Firefox, Edge, and compatible Chromium-based browsers on Windows, macOS, Linux, and ChromeOS. It does not currently provide a mobile app or Safari extension. Website compatibility also depends on the site's video player."
}
},
{
"@type": "Question",
"name": "What data does KoalaSync send from the browser?",
"acceptedAnswer": {
"@type": "Answer",
"text": "While you are in a room, KoalaSync sends the room, peer, and playback data needed for live synchronization. Display names and, depending on privacy settings, tab or media titles may be shared with room participants. Chat text is encrypted in the extension before transmission. KoalaSync does not create a persistent chat, browsing, synchronization, or watch history."
}
},
{
"@type": "Question",
"name": "How can official KoalaSync releases be verified?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The extension, relay server, build scripts, release workflow, and Dockerfile are public under the MIT License. Extension ZIPs on GitHub Releases and Docker images on GitHub Container Registry receive signed provenance attestations that bind their exact digest to this repository, a release tag and commit, and the public GitHub Actions workflow."
}
}
]
},
{
"@type": "BreadcrumbList",
"@id": "https://sync.koalastuff.net/help#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "KoalaSync",
"item": "https://sync.koalastuff.net/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Help",
"item": "https://sync.koalastuff.net/help"
}
]
}
]
}
</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 help topics</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="privacy">Privacy</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">Help center</span>
<h1>KoalaSync Help & Troubleshooting</h1>
<p class="support-lead">Start with the problem or question you have. These guides cover invites, browser access, video detection, encrypted chat, playback synchronization, supported devices, privacy, and release verification.</p>
</header>
<section class="support-topic-index" aria-labelledby="start-title">
<h2 id="start-title" class="support-section-title">Choose a help topic</h2>
<div class="support-topic-grid">
<article class="support-card support-topic-group">
<header class="support-topic-header">
<h3>Get connected</h3>
<p>Join a room and give KoalaSync the access it needs.</p>
</header>
<div class="support-topic-links" role="navigation" aria-label="Get connected help">
<a href="#invite-problems">The invite opens, but nothing happens</a>
<a href="site-access-help">KoalaSync cannot access the video site</a>
<a href="#missing-tab">The video tab does not appear</a>
</div>
</article>
<article class="support-card support-topic-group">
<header class="support-topic-header">
<h3>Playback & chat</h3>
<p>Fix the selected player, room chat, or synchronization.</p>
</header>
<div class="support-topic-links" role="navigation" aria-label="Playback and chat help">
<a href="#video-detection">The tab appears, but KoalaSync finds no usable video</a>
<a href="#room-chat">Chat does not work, or one person cannot see it</a>
<a href="#sync-problems">Everyone is connected, but playback does not sync</a>
</div>
</article>
<article class="support-card support-topic-group">
<header class="support-topic-header">
<h3>Using KoalaSync</h3>
<p>Understand what the extension does and supports.</p>
</header>
<div class="support-topic-links" role="navigation" aria-label="Using KoalaSync help">
<a href="#video-access">Does KoalaSync send the video to my friends?</a>
<a href="#supported-devices">Which devices and browsers are supported?</a>
</div>
</article>
<article class="support-card support-topic-group">
<header class="support-topic-header">
<h3>Privacy & trust</h3>
<p>See what is shared and how official builds are verified.</p>
</header>
<div class="support-topic-links" role="navigation" aria-label="Privacy and trust help">
<a href="#permissions">Why does the browser show such a broad warning?</a>
<a href="#data-handling">What data leaves my browser?</a>
<a href="#trust">Why should I trust KoalaSync?</a>
</div>
</article>
</div>
</section>
<section id="invite-problems" class="support-card support-anchor-section" aria-labelledby="invite-problems-title">
<span class="support-eyebrow">Invite links</span>
<h2 id="invite-problems-title">The invite opens, but nothing happens</h2>
<ol class="support-steps">
<li>Open the invite in the same desktop browser profile where KoalaSync is installed. Extensions installed in another browser or profile cannot receive the join request.</li>
<li>If you installed, enabled, or updated KoalaSync after opening the invite, reload the invite page once so the extension can connect to it.</li>
<li>Confirm that KoalaSync is allowed to run on <code>sync.koalastuff.net</code>. If the browser blocks access, follow the <a class="support-link" href="site-access-help">site-access guide</a>, then reload the invite.</li>
<li>Ask the sender to open KoalaSync and copy the invite link again. Use the complete link without removing anything after <code>#</code>; that part contains the room credentials and current chat secret.</li>
<li>Wait for the join page to report success. Joining the room does not select a video automatically, so open KoalaSync afterward and choose your local video tab under <strong>Sync</strong>.</li>
</ol>
<div class="support-callout" role="note">
<span class="support-callout-icon" aria-hidden="true">i</span>
<div>
<strong>The extension must be detected on the invite page.</strong>
<p>If the page still offers an install button even though KoalaSync is installed, the usual causes are a different browser profile, blocked website access, or a page that was opened before the extension became available.</p>
</div>
</div>
</section>
<section id="missing-tab" class="support-card support-anchor-section" aria-labelledby="missing-tab-title">
<span class="support-eyebrow">Tab list</span>
<h2 id="missing-tab-title">The video tab does not appear</h2>
<ol class="support-steps">
<li>Keep the video page open and reload it once.</li>
<li>Close and reopen the KoalaSync popup, then check <strong>Select Video</strong> again.</li>
<li>Open KoalaSync <strong>Settings</strong> and turn off <strong>Hide Clutter Tabs</strong>.</li>
<li>Return to the tab selector. If the tab now appears, the site was filtered as clutter by mistake.</li>
<li>If the tab is still missing, verify website access using the <a class="support-link" href="site-access-help">site-access guide</a>.</li>
</ol>
<div class="support-callout" role="note">
<span class="support-callout-icon" aria-hidden="true">i</span>
<div>
<strong>Tell us which domain was classified incorrectly.</strong>
<p>For a false clutter match, report the domain, whether the tab appears with the filter disabled, and whether the page contains a visible HTML5 video. A domain such as <code>video.example.com</code> is usually enough; do not publish a private URL containing account or session details.</p>
</div>
</div>
</section>
<section id="video-detection" class="support-card support-anchor-section" aria-labelledby="video-detection-title">
<span class="support-eyebrow">Video detection</span>
<h2 id="video-detection-title">The tab appears, but KoalaSync finds no usable video</h2>
<ul>
<li>Select the exact tab containing the visible player, not a login, search, or media-library tab.</li>
<li>Start the video once. Some websites create their video element only after playback begins.</li>
<li>Open the KoalaSync <strong>Status</strong> tab and inspect <strong>Video Debug Info</strong>.</li>
<li>If the report says <strong>NO VIDEO ELEMENT</strong>, note the video count and whether Shadow DOM was detected.</li>
<li>Temporarily disable other extensions that modify the player, then reload and test again.</li>
<li>Protected, heavily customized, or non-HTML5 players may require site-specific support.</li>
</ul>
</section>
<section id="room-chat" class="support-card support-anchor-section" aria-labelledby="room-chat-title">
<span class="support-eyebrow">Room chat</span>
<h2 id="room-chat-title">Chat does not work, or one person cannot see it</h2>
<p>Room chat was introduced in KoalaSync 3.0.0. Every participant who wants to send or read chat messages needs version 3.0.0 or newer. Older versions can still join a room and synchronize playback, but they do not support room chat.</p>
<ol class="support-steps">
<li>Update KoalaSync in every browser. Open the extension and check the version in the top-right corner. Each participant needs version <strong>3.0.0 or newer</strong>.</li>
<li>After updating, the room creator should leave the old room and click <strong>Create New Room</strong>. This creates a new chat encryption secret and includes it in the new invite link.</li>
<li>Send that newly copied invite link to your friends. Each friend must leave the old room and join through the link so their extension receives the same chat encryption secret.</li>
<li>Do not join by typing only the room ID and password, and do not reuse an old invite link beginning with <code>#join:</code>. Both can connect playback synchronization, but neither contains the chat secret.</li>
<li>Each participant must select their local video tab, then open <strong>Settings → Room Chat</strong> and turn on <strong>Enable Room Chat</strong>. The chat appears on the selected video page, not inside the extension popup.</li>
</ol>
<div class="support-callout" role="note">
<span class="support-callout-icon" aria-hidden="true">i</span>
<div>
<strong>Chat is live only.</strong>
<p>There is no server-side message history. Someone who joins later sees only messages sent after they joined. Keep the invite link private: it contains the chat secret used to decrypt messages in that room.</p>
</div>
</div>
</section>
<section id="sync-problems" class="support-card support-anchor-section" aria-labelledby="sync-problems-title">
<span class="support-eyebrow">Synchronization</span>
<h2 id="sync-problems-title">Everyone is connected, but playback does not sync</h2>
<ol class="support-steps">
<li>Update KoalaSync in every browser and confirm everyone uses the same extension version.</li>
<li>Make sure each participant selected the correct local video tab.</li>
<li>Reload the video page after changing website access, then select the tab again.</li>
<li>Test play, pause, and seeking separately and note which action fails.</li>
<li>Before anyone leaves the room, collect a debug report from every affected participant.</li>
</ol>
</section>
<section id="video-access" class="support-card support-anchor-section" aria-labelledby="video-access-title">
<span class="support-eyebrow">Video access</span>
<h2 id="video-access-title">Does KoalaSync send the video to my friends?</h2>
<p>No. KoalaSync does not stream, upload, proxy, download, or redistribute video content. It only sends the live room and playback events needed to keep actions such as play, pause, and seeking synchronized.</p>
<ul>
<li>Everyone watches the video locally in their own browser on the original website.</li>
<li>Each participant needs their own valid access to the website or streaming service.</li>
<li>KoalaSync does not bypass subscriptions, logins, DRM, regional restrictions, or platform rules.</li>
<li>Subtitles, audio tracks, fullscreen, and video quality remain local choices when the video player supports them.</li>
</ul>
</section>
<section id="supported-devices" class="support-card support-anchor-section" aria-labelledby="supported-devices-title">
<span class="support-eyebrow">Compatibility</span>
<h2 id="supported-devices-title">Which devices and browsers are supported?</h2>
<p>KoalaSync is currently a desktop browser extension. It supports Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave, Vivaldi, and other compatible Chromium-based desktop browsers on Windows, macOS, Linux, and ChromeOS.</p>
<ul>
<li>There is currently no KoalaSync mobile app for phones or tablets.</li>
<li>There is currently no Safari extension.</li>
<li>Website compatibility depends on the browser and the site's video player. Websites can change their players, DRM behavior, or browser restrictions at any time.</li>
<li>Protected, customized, or non-HTML5 players may need site-specific support even in a supported browser.</li>
</ul>
</section>
<section id="permissions" class="support-card support-anchor-section" aria-labelledby="permissions-title">
<span class="support-eyebrow">Privacy & permissions</span>
<h2 id="permissions-title">Why does the browser show such a broad warning?</h2>
<p>Browsers may say that KoalaSync can “read and change all your data on all websites.” The wording is broad because KoalaSync is designed to work with video players across many different websites instead of a short fixed list.</p>
<p>KoalaSync injects its synchronization script into the video tab you actively select. On that tab it needs to find and control the video element, synchronize play, pause, and seeking, and display the optional room chat overlay. Open tab titles are read to populate the tab selector. KoalaSync does not create a general browsing or watch history.</p>
<p><a class="support-link" href="privacy#extension-permissions">Read the complete permission and data-handling explanation</a></p>
</section>
<section id="data-handling" class="support-card support-anchor-section" aria-labelledby="data-handling-title">
<span class="support-eyebrow">Privacy</span>
<h2 id="data-handling-title">What data leaves my browser?</h2>
<p>KoalaSync only connects to a relay while you are actively in a room. During that session, the relay temporarily processes the data required to connect participants and synchronize playback.</p>
<ul>
<li><strong>Room and presence:</strong> room ID, peer ID, display name, connection state, and the information needed to join the room.</li>
<li><strong>Playback synchronization:</strong> actions and state such as play, pause, seeking, and the current playback position.</li>
<li><strong>Titles:</strong> tab or media titles may be shared with room participants depending on your title privacy settings. They can be limited or disabled.</li>
<li><strong>Room chat:</strong> message text is encrypted in the extension before transmission. The relay receives ciphertext and delivery metadata, but not the chat secret or readable message text.</li>
<li><strong>Stored locally:</strong> preferences, current room credentials, and the chat secret are stored in the browser. The chat secret is not sent to the relay. Decrypted chat entries remain only in the current page overlay and are not written to browser storage.</li>
<li><strong>Operational logs:</strong> the relay may log technical connection and security information such as IP addresses, protocol versions, socket or peer identifiers, and shortened room identifiers. Retention depends on the server infrastructure and configuration.</li>
</ul>
<p>KoalaSync does not create a persistent browsing, room, synchronization, chat, or watch history. Browser or operating-system notifications may retain notification text according to their own settings.</p>
<p><a class="support-link" href="privacy">Read the complete privacy policy</a></p>
</section>
<section id="trust" class="support-card support-anchor-section" aria-labelledby="trust-title">
<span class="support-eyebrow">Trust & verification</span>
<h2 id="trust-title">Why should I trust KoalaSync?</h2>
<p>Trust should not depend on promises alone. The KoalaSync browser extension, relay server, build scripts, release workflow, and Dockerfile used for official GitHub releases are public under the MIT License.</p>
<p>Official extension ZIPs on GitHub Releases and Docker images on GitHub Container Registry are built by the public GitHub Actions release workflow. Signed SLSA provenance attestations connect each artifact's exact digest to this repository, its release tag and commit, and the workflow that built it.</p>
<p>You can install KoalaSync from an official browser store, download the Chrome or Firefox ZIP from GitHub Releases, build the extension yourself, or run your own relay server. Manual ZIP installation uses your browser's developer or manual extension installation process.</p>
<div class="support-actions support-actions-trust">
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">View source code</a>
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync/releases/latest" target="_blank" rel="noopener">Download releases</a>
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync/blob/main/.github/workflows/release.yml" target="_blank" rel="noopener">View release workflow</a>
<a class="btn btn-secondary" href="./#self-hosting">Self-host the relay</a>
</div>
<details class="support-details">
<summary>Advanced: verify signed build provenance</summary>
<p>Install the GitHub CLI, download the release ZIP, and run:</p>
<pre><code>gh attestation verify koalasync-chrome.zip \
--repo Shik3i/KoalaSync</code></pre>
<p>To verify the current Docker image:</p>
<pre><code>gh attestation verify oci://ghcr.io/shik3i/koalasync:latest \
--repo Shik3i/KoalaSync</code></pre>
</details>
<div class="support-callout support-callout-warning" role="note">
<span class="support-callout-icon" aria-hidden="true">!</span>
<div>
<strong>What an attestation proves, and what it does not.</strong>
<p>An attestation proves the origin and build workflow of the GitHub release ZIP or GHCR image with that exact digest. It does not guarantee that software has no bugs or vulnerabilities. It also does not by itself prove that a package downloaded from a browser store is byte-for-byte identical to a GitHub release ZIP.</p>
</div>
</div>
</section>
<section id="report-issue" class="support-card support-anchor-section" aria-labelledby="report-issue-title">
<span class="support-eyebrow">Still not working?</span>
<h2 id="report-issue-title">Create a useful GitHub issue</h2>
<ol class="support-steps">
<li>Open KoalaSync, switch to <strong>Status</strong>, and click <strong>Copy Logs</strong> while the problem is still visible.</li>
<li>Open the KoalaSync GitHub bug-report form and sign in to GitHub if requested.</li>
<li>Describe the exact steps that reproduce the problem, what you expected, and what happened instead.</li>
<li>Add your browser, operating system, KoalaSync version, and the affected website or domain.</li>
<li>Paste the debug report, add a screenshot if useful, and review everything before publishing.</li>
</ol>
<div class="support-callout support-callout-warning" role="note">
<span class="support-callout-icon" aria-hidden="true">!</span>
<div>
<strong>GitHub issues are public.</strong>
<p>The copied report can include page titles and URLs, peer and room identifiers, participant names, and a custom server URL. Remove anything private. Never publish an invite link, room password, chat secret, account token, or a URL containing private session parameters.</p>
</div>
</div>
<div class="support-actions">
<a class="btn btn-primary" href="https://github.com/Shik3i/KoalaSync/issues/new?template=bug_report.md" target="_blank" rel="noopener">Create a bug report</a>
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener">Search existing issues</a>
</div>
</section>
<div class="support-actions">
<a class="btn btn-secondary" href="./">Back to KoalaSync</a>
<a class="btn btn-secondary" href="privacy">Privacy policy</a>
</div>
</main>
<footer>
<div class="container">
<p class="footer-ram">No chat or watch history. No tracking. Website access logs are deleted after 7 days.</p>
<p class="footer-copyright">&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<div class="footer-links">
<a href="./">Home</a>
<a href="help">Help</a>
<a href="privacy">Privacy</a>
<a href="https://koalastuff.net/legal">Legal notice</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>
+10 -1
View File
@@ -76,9 +76,18 @@ Compatibility depends on each website's player implementation and can change whe
- [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/koalasync/): Firefox
- [GitHub Releases](https://github.com/Shik3i/KoalaSync/releases): Release notes and manual packages
## Help and troubleshooting
- [Help Center](https://sync.koalastuff.net/help): Browser permissions, missing tabs, video detection, synchronization checks and safe bug reports
- [Website access guide](https://sync.koalastuff.net/site-access-help): Restore access when Chrome, Edge, Brave, Opera or Vivaldi blocks KoalaSync on a video site
- If a video tab is missing, disable `Hide Clutter Tabs` temporarily. If the tab then appears, report the affected domain so the filter can be corrected.
- If a listed tab has no detected video, start playback once and inspect `Status` → `Video Debug Info`.
- GitHub issues are public. Remove private page URLs, titles, room or peer identifiers, participant names, custom server URLs, credentials and session parameters before publishing logs.
- [GitHub bug report](https://github.com/Shik3i/KoalaSync/issues/new?template=bug_report.md): Include reproducible steps, browser, operating system, KoalaSync version and sanitized debug output
## Technical information
- Current website release: 3.0.0
- Current website release: 3.0.1
- License: MIT
- Extension runtime: dependency-free browser extension code
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
+2 -2
View File
@@ -14,8 +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",
"SUPPORT_BANNER_TEXT": "Brauchst du Hilfe mit KoalaSync?",
"SUPPORT_BANNER_CTA": "Help Center öffnen",
"SUPPORT_BANNER_DISMISS": "Hinweis schließen",
"HERO_TITLE": "Mach jedes Video zur <span class='hero-highlight'>Watch Party</span>.",
"HERO_SUBTITLE": "Schaut Videos gemeinsam aus der Ferne. Kein Konto, kein Tracking, keine Werbung. Link teilen und zusammen starten.",
+2 -2
View File
@@ -14,8 +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",
"SUPPORT_BANNER_TEXT": "Need help with KoalaSync?",
"SUPPORT_BANNER_CTA": "Open Help Center",
"SUPPORT_BANNER_DISMISS": "Dismiss notification",
"HERO_TITLE": "Turn any video into a <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Watch videos together remotely. No account, tracking, or ads. Share a link and start together.",
+2 -2
View File
@@ -14,8 +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",
"SUPPORT_BANNER_TEXT": "¿Necesitas ayuda con KoalaSync?",
"SUPPORT_BANNER_CTA": "Abrir el centro de ayuda",
"SUPPORT_BANNER_DISMISS": "Cerrar aviso",
"HERO_TITLE": "Convierte cualquier vídeo en una <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Ved vídeos juntos a distancia. Sin cuenta, seguimiento ni anuncios. Comparte un enlace y empezad juntos.",
+2 -2
View File
@@ -14,8 +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 naccède pas à votre site vidéo ?",
"SUPPORT_BANNER_CTA": "Réparer laccès au site",
"SUPPORT_BANNER_TEXT": "Besoin daide avec KoalaSync ?",
"SUPPORT_BANNER_CTA": "Ouvrir le centre daide",
"SUPPORT_BANNER_DISMISS": "Fermer lavertissement",
"HERO_TITLE": "Transformez n'importe quelle vidéo en <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Regardez des vidéos ensemble à distance. Sans compte, suivi ni publicité. Partagez un lien et démarrez ensemble.",
+2 -2
View File
@@ -14,8 +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 laccesso al sito",
"SUPPORT_BANNER_TEXT": "Hai bisogno di aiuto con KoalaSync?",
"SUPPORT_BANNER_CTA": "Apri il centro assistenza",
"SUPPORT_BANNER_DISMISS": "Chiudi avviso",
"HERO_TITLE": "Trasforma qualsiasi video in una <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Guarda video insieme a distanza. Nessun account, tracciamento o pubblicità. Condividi un link e iniziate insieme.",
+2 -2
View File
@@ -14,8 +14,8 @@
"TWITTER_DESCRIPTION": "Netflix、Emby、Jellyfin、YouTube、Twitch、ほぼすべてのHTML5動画でウォッチパーティーを開催。オープンソース、プライバシー重視、アカウント不要。",
"NAV_FEATURES": "機能",
"NAV_HOW_IT_WORKS": "仕組み",
"SUPPORT_BANNER_TEXT": "KoalaSync が動画サイトにアクセスできませんか?",
"SUPPORT_BANNER_CTA": "サイトアクセスを修復",
"SUPPORT_BANNER_TEXT": "KoalaSync についてお困りですか?",
"SUPPORT_BANNER_CTA": "ヘルプセンターを開く",
"SUPPORT_BANNER_DISMISS": "通知を閉じる",
"HERO_TITLE": "どんな動画も<span class='hero-highlight'>ウォッチパーティー</span>に。",
"HERO_SUBTITLE": "離れた場所から一緒に動画を視聴。アカウント、追跡、広告はありません。リンクを共有して一緒に再生できます。",
+2 -2
View File
@@ -14,8 +14,8 @@
"TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch 및 거의 모든 HTML5 영상에서 워치 파티를 열어보세요. 오픈소스, 개인정보 보호 중심, 계정 불필요.",
"NAV_FEATURES": "특징",
"NAV_HOW_IT_WORKS": "작동 방식",
"SUPPORT_BANNER_TEXT": "KoalaSync가 동영상 사이트에 액세스할 수 없나요?",
"SUPPORT_BANNER_CTA": "사이트 액세스 복구",
"SUPPORT_BANNER_TEXT": "KoalaSync에 도움이 필요하신가요?",
"SUPPORT_BANNER_CTA": "도움말 센터 열기",
"SUPPORT_BANNER_DISMISS": "알림 닫기",
"HERO_TITLE": "어떤 영상이든 <span class='hero-highlight'>워치 파티</span>로.",
"HERO_SUBTITLE": "멀리서도 함께 비디오를 시청하세요. 계정, 추적, 광고가 없습니다. 링크를 공유하고 함께 시작하세요.",
+2 -2
View File
@@ -14,8 +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",
"SUPPORT_BANNER_TEXT": "Hulp nodig met KoalaSync?",
"SUPPORT_BANNER_CTA": "Helpcentrum openen",
"SUPPORT_BANNER_DISMISS": "Melding sluiten",
"HERO_TITLE": "Maak van elke video een <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Bekijk op afstand samen videos. Geen account, tracking of advertenties. Deel een link en start samen.",
+2 -2
View File
@@ -14,8 +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",
"SUPPORT_BANNER_TEXT": "Potrzebujesz pomocy z KoalaSync?",
"SUPPORT_BANNER_CTA": "Otwórz centrum pomocy",
"SUPPORT_BANNER_DISMISS": "Zamknij powiadomienie",
"HERO_TITLE": "Zamień dowolne wideo w <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Oglądajcie wspólnie filmy na odległość. Bez konta, śledzenia i reklam. Udostępnij link i zacznijcie razem.",
+2 -2
View File
@@ -14,8 +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",
"SUPPORT_BANNER_TEXT": "Precisa de ajuda com o KoalaSync?",
"SUPPORT_BANNER_CTA": "Abrir a Central de Ajuda",
"SUPPORT_BANNER_DISMISS": "Fechar aviso",
"HERO_TITLE": "Transforme qualquer vídeo em uma <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Assista a vídeos com outras pessoas à distância. Sem conta, rastreamento ou anúncios. Compartilhe um link e comecem juntos.",
+2 -2
View File
@@ -14,8 +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",
"SUPPORT_BANNER_TEXT": "Precisa de ajuda com o KoalaSync?",
"SUPPORT_BANNER_CTA": "Abrir o centro de ajuda",
"SUPPORT_BANNER_DISMISS": "Fechar aviso",
"HERO_TITLE": "Transforma qualquer vídeo numa <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Vê vídeos com outras pessoas à distância. Sem conta, rastreio ou publicidade. Partilha um link e comecem juntos.",
+2 -2
View File
@@ -14,8 +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": "Исправить доступ к сайту",
"SUPPORT_BANNER_TEXT": "Нужна помощь с KoalaSync?",
"SUPPORT_BANNER_CTA": "Открыть справочный центр",
"SUPPORT_BANNER_DISMISS": "Закрыть уведомление",
"HERO_TITLE": "Преврати любое видео в <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Смотрите видео вместе на расстоянии. Без аккаунта, отслеживания и рекламы. Поделитесь ссылкой и начните вместе.",
+2 -2
View File
@@ -14,8 +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",
"SUPPORT_BANNER_TEXT": "KoalaSync ile ilgili yardıma mı ihtiyacınız var?",
"SUPPORT_BANNER_CTA": "Yardım Merkezini aç",
"SUPPORT_BANNER_DISMISS": "Bildirimi kapat",
"HERO_TITLE": "Herhangi bir videoyu <span class='hero-highlight'>watch party</span>'ye dönüştür.",
"HERO_SUBTITLE": "Uzaktan birlikte video izleyin. Hesap, takip veya reklam yok. Bağlantıyı paylaşın ve birlikte başlatın.",
+2 -2
View File
@@ -14,8 +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": "Виправити доступ до сайту",
"SUPPORT_BANNER_TEXT": "Потрібна допомога з KoalaSync?",
"SUPPORT_BANNER_CTA": "Відкрити довідковий центр",
"SUPPORT_BANNER_DISMISS": "Закрити сповіщення",
"HERO_TITLE": "Перетвори будь-яке відео на <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Дивіться відео разом на відстані. Без облікового запису, відстеження та реклами. Поділіться посиланням і почніть разом.",
+2 -2
View File
@@ -14,8 +14,8 @@
"TWITTER_DESCRIPTION": "在 Netflix、Emby、Jellyfin、YouTube、Twitch 和几乎任意 HTML5 视频上发起一起看派对。开源、重视隐私、无需账号。",
"NAV_FEATURES": "特征",
"NAV_HOW_IT_WORKS": "它是如何运作的",
"SUPPORT_BANNER_TEXT": "KoalaSync 无法访问视频网站",
"SUPPORT_BANNER_CTA": "修复网站访问权限",
"SUPPORT_BANNER_TEXT": "需要 KoalaSync 帮助",
"SUPPORT_BANNER_CTA": "打开帮助中心",
"SUPPORT_BANNER_DISMISS": "关闭提示",
"HERO_TITLE": "把任意视频变成<span class='hero-highlight'>一起看派对</span>。",
"HERO_SUBTITLE": "远程一起看视频。无需账户、无跟踪、无广告。分享链接并一起开始。",
+1 -1
View File
@@ -243,7 +243,7 @@
</p>
</section>
<section>
<section id="extension-permissions">
<h2>6. Extension Permissions</h2>
<p>
To fulfill its technical purpose, the browser extension requires certain permissions. Each permission is used exclusively for core functionality:
+9 -7
View File
@@ -9,11 +9,11 @@
<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">
<link rel="canonical" href="https://sync.koalastuff.net/site-access-help">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/site-access-help">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/site-access-help">
<meta property="og:type" content="website">
<meta property="og:url" content="https://sync.koalastuff.net/site-access-help.html">
<meta property="og:url" content="https://sync.koalastuff.net/site-access-help">
<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">
@@ -35,10 +35,10 @@
"datePublished": "2026-07-15T10:24:59Z",
"dateModified": "2026-07-19T19:20:05Z",
"inLanguage": "en",
"url": "https://sync.koalastuff.net/site-access-help.html",
"url": "https://sync.koalastuff.net/site-access-help",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://sync.koalastuff.net/site-access-help.html"
"@id": "https://sync.koalastuff.net/site-access-help"
},
"author": {
"@type": "Organization",
@@ -71,6 +71,7 @@
</a>
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
<a href="./">Home</a>
<a href="help">Help</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">GitHub</a>
</div>
<div class="nav-right">
@@ -148,7 +149,7 @@
</section>
<div class="support-actions">
<a class="btn btn-primary" href="./">Back to KoalaSync</a>
<a class="btn btn-primary" href="help">Back to Help Center</a>
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener">Report a reproducible issue</a>
</div>
</main>
@@ -159,6 +160,7 @@
<p class="footer-copyright">&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<div class="footer-links">
<a href="https://koalastuff.net/legal">Legal notice</a>
<a href="help">Help</a>
<a href="privacy.html">Privacy</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">GitHub</a>
</div>
+3
View File
@@ -182,4 +182,7 @@
font-size: 1rem;
gap: 0.4rem;
}
.logo-area > span:first-of-type {
display: none;
}
}
+198 -6
View File
@@ -1,4 +1,4 @@
/* --- Site-access troubleshooting page --- */
/* --- Help and site-access support pages --- */
.support-page {
min-height: 100vh;
}
@@ -79,8 +79,7 @@
}
.support-content > .support-card + .support-card,
.support-grid + .support-card,
.support-card + .support-grid,
.support-card + .support-topic-grid,
.support-callout + .support-card {
margin-top: 1.25rem;
}
@@ -130,13 +129,129 @@
margin-top: 0.6rem;
}
.support-grid {
.support-topic-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
margin-top: 1.25rem;
}
.support-topic-index {
margin-bottom: 2.75rem;
}
.support-section-title {
margin: 0 0 0.4rem;
color: var(--text);
text-align: center;
font-size: clamp(1.45rem, 3vw, 1.9rem);
}
.support-topic-group {
position: relative;
overflow: hidden;
padding: 0;
border-top-color: color-mix(in oklch, var(--accent) 55%, var(--card-border));
}
.support-topic-group::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg, var(--accent), color-mix(in oklch, var(--accent) 12%, transparent) 72%, transparent);
}
.support-topic-header {
min-height: 7.4rem;
padding: 1.55rem 1.6rem 1.25rem;
}
.support-topic-header h3 {
margin: 0 0 0.45rem;
color: var(--text);
font-size: 1.2rem;
letter-spacing: -0.015em;
}
.support-topic-header p {
max-width: 34rem;
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.55;
}
.support-topic-links {
display: block;
border-top: 1px solid var(--card-border);
}
.support-topic-links a {
display: flex;
min-height: 3.85rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1.6rem;
color: var(--text);
font-size: 0.94rem;
font-weight: 700;
line-height: 1.4;
text-decoration: none;
transition: background-color 160ms ease, color 160ms ease, padding-left 160ms ease;
}
.support-topic-links a + a {
border-top: 1px solid color-mix(in oklch, var(--card-border) 78%, transparent);
}
.support-topic-links a::after {
content: '';
flex: 0 0 auto;
color: var(--accent);
font-size: 1.45rem;
font-weight: 500;
line-height: 1;
transition: transform 160ms ease;
}
.support-topic-links a:hover {
padding-left: 1.75rem;
background: color-mix(in oklch, var(--accent) 9%, transparent);
color: color-mix(in oklch, var(--text) 88%, var(--accent));
}
.support-topic-links a:hover::after {
transform: translateX(3px);
}
.support-topic-links a:focus-visible {
position: relative;
z-index: 1;
outline: 3px solid color-mix(in oklch, var(--accent) 50%, transparent);
outline-offset: -3px;
background: color-mix(in oklch, var(--accent) 12%, transparent);
}
.support-anchor-section {
scroll-margin-top: 7rem;
}
.support-anchor-section code {
overflow-wrap: anywhere;
color: var(--text);
}
.support-callout-warning {
border-color: color-mix(in oklch, var(--accent-terracotta) 50%, transparent);
background: color-mix(in oklch, var(--accent-terracotta) 9%, var(--card-surface));
}
.support-callout-warning .support-callout-icon {
background: var(--accent-terracotta);
}
.support-steps {
list-style: none;
padding: 0 !important;
@@ -214,28 +329,105 @@
.support-actions .btn {
min-width: 180px;
justify-content: center;
text-align: center;
}
.support-actions-trust {
margin: 1.5rem 0;
}
.support-details {
margin-top: 1.25rem;
border: 1px solid var(--card-border);
border-radius: 16px;
background: color-mix(in oklch, var(--card) 82%, transparent);
}
.support-details summary {
padding: 1rem 1.15rem;
color: var(--text);
cursor: pointer;
font-weight: 800;
}
.support-details summary:focus-visible {
outline: 3px solid color-mix(in oklch, var(--accent) 45%, transparent);
outline-offset: 3px;
border-radius: 14px;
}
.support-details[open] {
padding-bottom: 1rem;
}
.support-details[open] summary {
margin-bottom: 0.25rem;
}
.support-details p,
.support-details pre {
margin: 0.75rem 1.15rem 0;
}
.support-details pre {
overflow-x: auto;
border: 1px solid color-mix(in oklch, var(--accent) 22%, var(--card-border));
border-radius: 12px;
background: color-mix(in oklch, var(--bg) 82%, black);
padding: 1rem;
color: var(--text);
font-size: 0.82rem;
line-height: 1.6;
}
.support-details code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
html.theme-light .support-card,
html.theme-light .support-callout {
html.theme-light .support-callout,
html.theme-light .support-details {
background-color: var(--card-surface);
box-shadow: 0 18px 45px rgba(44, 62, 37, 0.1);
}
html.theme-light .support-details pre {
background: color-mix(in oklch, var(--bg) 92%, white);
}
@media (max-width: 720px) {
.support-content {
width: min(100% - 1rem, 980px);
padding-top: 7.5rem;
}
.support-grid {
.support-topic-grid {
grid-template-columns: 1fr;
}
.support-topic-index {
margin-bottom: 2.25rem;
}
.support-card {
border-radius: 18px;
}
.support-topic-header {
min-height: 0;
padding: 1.35rem 1.3rem 1rem;
}
.support-topic-links a {
min-height: 3.65rem;
padding: 0.85rem 1.3rem;
}
.support-topic-links a:hover {
padding-left: 1.4rem;
}
.support-callout {
grid-template-columns: 1fr;
}
+8 -2
View File
@@ -116,7 +116,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "3.0.0",
"softwareVersion": "3.0.1",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
@@ -364,7 +364,7 @@
<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">
<a href="/help" class="site-access-banner-link">
<span>{{SUPPORT_BANNER_CTA}}</span>
</a>
<button class="site-access-banner-dismiss" type="button" aria-label="{{SUPPORT_BANNER_DISMISS}}">
@@ -1531,6 +1531,11 @@
</details>
</div>
</details>
<div class="cta-group" data-reveal style="justify-content: center; margin-top: 1.5rem;">
<a href="{{ASSET_PATH}}help" class="btn btn-secondary">
<span lang="en">Need help? Open the Help Center</span>
</a>
</div>
</div>
</section>
@@ -1576,6 +1581,7 @@
<div class="footer-links">
<a href="https://koalastuff.net/legal"><span>{{FOOTER_LEGAL}}</span></a>
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}"><span>{{FOOTER_PRIVACY}}</span></a>
<a href="{{ASSET_PATH}}help"><span lang="en">Help</span></a>
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
<a href="https://support.koalastuff.net" target="_blank" rel="noopener" title="{{FOOTER_SUPPORT}}" class="footer-support-btn">
<span aria-hidden="true"></span>
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "3.0.0",
"date": "2026-07-26T00:25:06Z"
"version": "3.0.1",
"date": "2026-07-26T03:56:23Z"
}