Files
KoalaSync/website/app.js
T
2026-07-15 13:20:00 +02:00

1779 lines
82 KiB
JavaScript

// KoalaSync Landing Page Logic
document.addEventListener('DOMContentLoaded', () => {
const siteAccessBanner = document.querySelector('.site-access-banner');
const siteAccessBannerDismiss = document.querySelector('.site-access-banner-dismiss');
if (siteAccessBanner && siteAccessBannerDismiss) {
siteAccessBannerDismiss.addEventListener('click', () => {
siteAccessBanner.hidden = true;
});
}
// Mockup Video Title Randomization on Load
const SERIES_NAMES = [
'Stranger Things',
'Wednesday',
'The Boys',
'Loki',
'Arcane',
'Dark',
'One Piece',
'Lupin'
];
try {
const chosenSeries = SERIES_NAMES[Math.floor(Math.random() * SERIES_NAMES.length)];
const startEp = Math.floor(Math.random() * 7) + 1; // Random episode between 1 and 7
const ep1 = `${chosenSeries} - S1E${startEp}`;
const ep2 = `${chosenSeries} - S1E${startEp + 1}`;
document.querySelectorAll('.demo-title-text-ep1').forEach(el => {
el.textContent = ep1;
});
document.querySelectorAll('.demo-title-text-ep2').forEach(el => {
el.textContent = ep2;
});
} catch (err) {
console.warn(err);
}
// Scroll Progress Indicator
try {
const progressBar = document.querySelector('.scroll-progress-bar');
if (progressBar) {
window.addEventListener('scroll', () => {
const winScroll = document.body.scrollTop || document.documentElement.scrollTop;
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
const scrolled = height > 0 ? (winScroll / height) * 100 : 0;
progressBar.style.width = scrolled + '%';
}, { passive: true });
}
} catch (err) {
console.warn(err);
}
const safeGetLocalStorage = (key) => {
try {
return localStorage.getItem(key);
} catch (_) {
return null;
}
};
const safeSetLocalStorage = (key, val) => {
try {
localStorage.setItem(key, val);
} catch (_) {
return;
}
};
const restartAnimation = (el, className) => {
if (!el) return;
el.classList.remove(className);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.add(className);
});
});
};
// Populated by the forest renderer further down (no-op until then)
let forestGreet = null;
// Theme toggle (saved preference wins; otherwise follows system preference,
// pre-paint class is applied by lang-init.js to avoid a flash)
const themeToggle = document.getElementById('theme-toggle');
const themeMeta = document.querySelector('meta[name="theme-color"]');
const themeStorageKey = 'koala_theme';
const systemThemeQuery = (() => {
try {
return window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
} catch (_) {
return null;
}
})();
const getSystemTheme = () => systemThemeQuery && systemThemeQuery.matches ? 'light' : 'dark';
const getSavedTheme = () => {
const savedTheme = safeGetLocalStorage(themeStorageKey);
return savedTheme === 'light' || savedTheme === 'dark' ? savedTheme : null;
};
const getCurrentTheme = () => document.documentElement.classList.contains('theme-light') ? 'light' : 'dark';
const applyTheme = (theme) => {
document.documentElement.classList.toggle('theme-light', theme === 'light');
};
applyTheme(getSavedTheme() || getSystemTheme());
const syncThemeMeta = () => {
if (themeMeta) {
themeMeta.setAttribute('content', document.documentElement.classList.contains('theme-light') ? '#f0efe7' : '#10190e');
}
};
syncThemeMeta();
const syncSystemTheme = () => {
if (getSavedTheme()) return;
applyTheme(getSystemTheme());
syncThemeMeta();
};
if (systemThemeQuery) {
if (typeof systemThemeQuery.addEventListener === 'function') {
systemThemeQuery.addEventListener('change', syncSystemTheme);
} else if (typeof systemThemeQuery.addListener === 'function') {
systemThemeQuery.addListener(syncSystemTheme);
}
}
if (themeToggle) {
themeToggle.addEventListener('click', () => {
const nextTheme = getCurrentTheme() === 'light' ? 'dark' : 'light';
applyTheme(nextTheme);
safeSetLocalStorage(themeStorageKey, nextTheme);
syncThemeMeta();
// Switching into light mode flares the horizon once (sunrise)
const scene = document.querySelector('.bg-nature');
if (nextTheme === 'light' && scene && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
restartAnimation(scene, 'sunrise');
setTimeout(() => scene.classList.remove('sunrise'), 1700);
}
});
}
// Highlight the CTA for the visitor's browser: it expands to the full
// "Add to X" label while the others stay compact. Unknown browsers keep
// all three compact.
try {
const ua = navigator.userAgent;
let detected = null;
if (/Firefox\//.test(ua) && !/Seamonkey\//.test(ua)) {
detected = 'firefox';
} else if (/Chrome\//.test(ua) || /Chromium\//.test(ua) || /Edg\//.test(ua) || /OPR\//.test(ua)) {
detected = 'chrome';
}
if (detected) {
const btn = document.querySelector('.btn-install[data-browser="' + detected + '"]');
if (btn) btn.classList.add('is-detected');
}
} catch (_) { /* leave all buttons compact */ }
// Keep the look-down koala anchored above the highlighted install button
// when the CTA row wraps on tablet/mobile.
const syncHeroMascotAnchor = () => {
const heroText = document.querySelector('.hero-text');
const mascot = heroText ? heroText.querySelector('.hero-mascot-container') : null;
const ctaGroup = heroText ? heroText.querySelector('.cta-group') : null;
if (!heroText || !mascot || !ctaGroup) return;
const target = ctaGroup.querySelector('.btn-install.is-detected') ||
ctaGroup.querySelector('.btn-install[data-browser="chrome"]') ||
ctaGroup.querySelector('.btn-install');
if (!target) return;
const mascotRect = mascot.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
if (!mascotRect.width || !targetRect.width) return;
const mascotCenter = mascotRect.left + mascotRect.width / 2;
const targetCenter = targetRect.left + targetRect.width / 2;
mascot.style.setProperty('--hero-mascot-offset', Math.round(targetCenter - mascotCenter) + 'px');
};
const scheduleHeroMascotAnchor = () => requestAnimationFrame(syncHeroMascotAnchor);
window.addEventListener('load', scheduleHeroMascotAnchor, { once: true });
window.addEventListener('resize', scheduleHeroMascotAnchor, { passive: true });
if ('ResizeObserver' in window) {
const heroText = document.querySelector('.hero-text');
const ctaGroup = heroText ? heroText.querySelector('.cta-group') : null;
const mascot = heroText ? heroText.querySelector('.hero-mascot-container') : null;
const mascotObserver = new window.ResizeObserver(scheduleHeroMascotAnchor);
if (ctaGroup) mascotObserver.observe(ctaGroup);
if (mascot) mascotObserver.observe(mascot);
}
// Open collapsed section blocks when the URL directly targets them
const hashCollapseMap = { '#faq': 'faq-collapse', '#self-hosting': 'selfhost-collapse' };
const collapseId = hashCollapseMap[window.location.hash];
if (collapseId) {
const collapseEl = document.getElementById(collapseId);
if (collapseEl) collapseEl.open = true;
}
// Scroll Reveal Logic (IntersectionObserver for performance)
const revealElements = document.querySelectorAll('[data-reveal]');
// Hash navigation must never land on an intentionally transparent state.
// Reveal the destination before the smooth scroll begins; the observer
// continues to animate sections reached through ordinary scrolling.
const revealHashTarget = () => {
const id = decodeURIComponent(window.location.hash.slice(1));
const target = id ? document.getElementById(id) : null;
if (!target) return;
if (target.matches('[data-reveal]')) target.classList.add('revealed');
target.querySelectorAll('[data-reveal]').forEach(el => el.classList.add('revealed'));
};
revealHashTarget();
window.addEventListener('hashchange', revealHashTarget);
if ('IntersectionObserver' in window) {
const revealObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('revealed');
revealObserver.unobserve(entry.target);
}
});
}, {
rootMargin: '0px 0px 12% 0px',
threshold: 0.05
});
revealElements.forEach(el => revealObserver.observe(el));
} else {
// Fallback: without IntersectionObserver support, reveal everything
// immediately so no content can ever stay hidden.
revealElements.forEach(el => el.classList.add('revealed'));
}
// 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();
}
});
}, { threshold: 0.3 });
document.querySelectorAll('section[id], header[id]').forEach(el => sectionObserver.observe(el));
}
// Navbar scroll effect (class-based so it follows the active theme)
const nav = document.querySelector('nav');
window.addEventListener('scroll', () => {
nav.classList.toggle('nav-scrolled', window.scrollY > 50);
}, { passive: true });
// Bamboo forest: scroll journey + parallax (background layers only).
// The vertical drift maps scroll PROGRESS (0..1 over the whole page) onto
// the stalks' 20% overscan, so the parallax runs for the entire scroll
// instead of saturating after a few hundred pixels. The same progress
// value crossfades the scene from sunlit canopy into misty dusk, and on
// pointer devices the layers tilt slightly toward the cursor.
const bambooFar = document.getElementById('bamboo-far');
const bambooMid = document.getElementById('bamboo-mid');
const bambooNear = document.getElementById('bamboo-near');
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (bambooFar && bambooNear && !prefersReducedMotion) {
const depthDay = document.querySelector('.bg-depth-day');
const fireflies = Array.from(document.querySelectorAll('.firefly-wrap'), (el) => ({ el, x: 0, y: 0 }));
const canHover = window.matchMedia('(hover: hover)').matches;
let mouseX = 0;
let mouseY = 0;
let pointerX = -9999;
let pointerY = -9999;
let framePending = false;
const renderForest = () => {
framePending = false;
bambooFar.style.transform = `translateX(${(-mouseX * 5).toFixed(1)}px)`;
bambooNear.style.transform = `translateX(${(mouseX * 11).toFixed(1)}px)`;
if (bambooMid) bambooMid.style.transform = `translateX(${(mouseX * 4).toFixed(1)}px)`;
if (depthDay) depthDay.style.transform = `translate(${(-mouseX * 2.5).toFixed(1)}px, ${(mouseY * 1.5).toFixed(1)}px)`;
// Fireflies shy away from the cursor and drift back once it leaves
let settling = false;
for (const fly of fireflies) {
const cx = fly.el.offsetLeft + 2;
const cy = fly.el.offsetTop + 2;
const dx = cx - pointerX;
const dy = cy - pointerY;
const dist = Math.hypot(dx, dy);
let tx = 0;
let ty = 0;
if (dist < 130 && dist > 0.001) {
const push = ((130 - dist) / 130) * 46;
tx = (dx / dist) * push;
ty = (dy / dist) * push;
}
fly.x += (tx - fly.x) * 0.14;
fly.y += (ty - fly.y) * 0.14;
if (Math.abs(tx - fly.x) > 0.4 || Math.abs(ty - fly.y) > 0.4) settling = true;
fly.el.style.transform = `translate(${fly.x.toFixed(1)}px, ${fly.y.toFixed(1)}px)`;
}
if (settling) requestForestFrame();
};
const requestForestFrame = () => {
if (!framePending) {
framePending = true;
requestAnimationFrame(renderForest);
}
};
// Let one firefly greet newly reached sections (used by the section
// observer below; index rotates so it is not always the same one)
let helloIndex = 0;
forestGreet = () => {
if (!fireflies.length) return;
const wrap = fireflies[helloIndex % fireflies.length].el;
helloIndex += 1;
restartAnimation(wrap, 'firefly-hello');
setTimeout(() => wrap.classList.remove('firefly-hello'), 1300);
};
window.addEventListener('resize', requestForestFrame, { passive: true });
if (canHover) {
window.addEventListener('pointermove', (e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
pointerX = e.clientX;
pointerY = e.clientY;
requestForestFrame();
}, { passive: true });
window.addEventListener('pointerleave', () => {
pointerX = -9999;
pointerY = -9999;
requestForestFrame();
});
}
renderForest();
}
// Eucalyptus click confetti: leaves burst from wherever the visitor
// presses. Buttons and other controls shed leaves along their whole
// width; anywhere else a small fan rises from the pointer itself. The
// hero walkthrough's scripted cursor calls burstFromElement directly so
// its fake clicks scatter leaves too. Rate-capped, pointer-events:none —
// it can never block a click or flood the DOM.
let burstFromElement = () => {};
if (!prefersReducedMotion) {
const spawnLeaf = (x, y) => {
const leaf = document.createElement('span');
// Three tones of the palette: deep green, sage, a dash of amber
const tone = Math.random();
leaf.className = 'click-leaf' + (tone < 0.25 ? ' click-leaf-amber' : tone < 0.55 ? ' click-leaf-sage' : '');
// Fan out upward to a burst peak; the keyframes brake there and
// let the leaf sink past it while the inner element rocks.
const angle = (-90 + (Math.random() - 0.5) * 150) * Math.PI / 180;
const dist = 26 + Math.random() * 38;
const dur = 0.9 + Math.random() * 0.5;
leaf.style.left = x + 'px';
leaf.style.top = y + 'px';
leaf.style.setProperty('--leaf-x', (Math.cos(angle) * dist).toFixed(1) + 'px');
leaf.style.setProperty('--leaf-y', (Math.sin(angle) * dist).toFixed(1) + 'px');
leaf.style.setProperty('--leaf-rot', ((Math.random() - 0.5) * 160).toFixed(0) + 'deg');
leaf.style.setProperty('--leaf-scale', (0.55 + Math.random() * 0.6).toFixed(2));
leaf.style.setProperty('--leaf-dur', dur.toFixed(2) + 's');
leaf.style.setProperty('--flutter-dur', (0.4 + Math.random() * 0.35).toFixed(2) + 's');
leaf.appendChild(document.createElement('i'));
document.body.appendChild(leaf);
setTimeout(() => leaf.remove(), dur * 1000 + 150);
};
burstFromElement = (el) => {
if (!el) return;
const r = el.getBoundingClientRect();
if (!r.width || r.bottom < 0 || r.top > window.innerHeight) return;
// Leaf count scales with the control's width so a wide demo card
// sheds a few more than a compact icon button, capped for subtlety.
const count = Math.max(4, Math.min(9, Math.round(r.width / 26)));
for (let i = 0; i < count; i++) {
const x = r.left + 4 + Math.random() * Math.max(1, r.width - 8);
// Spawn along the upper edge, like leaves knocked off the top
const y = r.top + r.height * (0.1 + Math.random() * 0.4);
spawnLeaf(x, y);
}
};
// Listen for `click`, not `pointerdown`: a click never fires while the
// page is being scrolled, so touch users no longer trigger a shower of
// leaves every time they swipe. Leaves are also only shed by actual
// interactive controls (links/buttons and the demo widgets), so a stray
// tap on plain text or an illustration stays quiet.
let lastBurst = 0;
document.addEventListener('click', (e) => {
const control = e.target.closest('a, button, [role="button"], .btn, .mock-tab, select, .demo-progress');
if (!control) return;
const now = Date.now();
if (now - lastBurst < 180) return;
lastBurst = now;
burstFromElement(control);
}, { passive: true });
}
// Invite Detection & Bridge
const checkInvite = () => {
const isJoinPage = window.location.pathname.includes('join');
// Dev Simulation Mode via URL Search Parameter (?dev=success) or Hash (#dev=success / #devsuccess)
const urlParams = new URLSearchParams(window.location.search);
let devMode = urlParams.get('dev');
if (!devMode) {
const hashClean = window.location.hash.startsWith('#') ? window.location.hash.substring(1) : window.location.hash;
const hashParams = new URLSearchParams(hashClean);
devMode = hashParams.get('dev');
}
if (!devMode) {
if (window.location.hash.includes('devsuccess') || window.location.search.includes('devsuccess')) devMode = 'success';
if (window.location.hash.includes('devfailure') || window.location.search.includes('devfailure')) devMode = 'failure';
}
if (isJoinPage && devMode) {
setTimeout(() => {
const displayRoom = document.getElementById('display-room-id');
const actions = document.getElementById('join-actions');
if (displayRoom) displayRoom.textContent = 'DEV-ROOM';
if (actions) {
actions.innerHTML = `
<div class="joining-spinner" style="text-align:center; padding: 1rem;">
<div class="join-spinner"></div>
<div style="font-weight: 600; color: var(--accent);">
<span lang="en">Simulating connection (DEV)...</span><span lang="de">Verbindung wird simuliert (DEV)...</span>
</div>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
<span lang="en">Simulating status event in 1.5 seconds.</span><span lang="de">Status-Event wird in 1,5 Sekunden simuliert.</span>
</p>
</div>
`;
setTimeout(() => {
window.dispatchEvent(new CustomEvent('KOALASYNC_STATUS', {
detail: {
success: devMode === 'success',
message: devMode === 'failure' ? 'Simulated Connection Timeout!' : ''
}
}));
}, 1500);
}
}, 600);
return;
}
// Use a short timeout to let the bridge script initialize its dataset attribute
setTimeout(() => {
const isInstalled = document.documentElement.dataset.koalasyncInstalled === 'true';
if (window.location.hash.startsWith('#join:')) {
const parts = window.location.hash.split(':');
if (parts.length >= 3) {
const roomId = parts[1];
const password = parts[2];
const serverFlag = parts[3] || '0';
const serverUrl = parts[4] ? decodeURIComponent(parts[4]) : '';
if (isJoinPage) {
const displayRoom = document.getElementById('display-room-id');
const actions = document.getElementById('join-actions');
if (displayRoom) displayRoom.textContent = roomId;
if (actions) {
if (!isInstalled) {
const isFirefox = navigator.userAgent.includes('Firefox');
if (isFirefox) {
actions.innerHTML = `
<div class="join-card-actions">
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/" class="btn btn-primary btn-firefox">
<img src="assets/firefox.svg" alt="Firefox" width="20" style="display: block;">
<span lang="en">GET IT ON MOZILLA ADD-ONS</span><span lang="de">IM FIREFOX ADD-ON STORE HERUNTERLADEN</span>
</a>
<a href="https://github.com/shik3i/KoalaSync" target="_blank" class="btn btn-secondary">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<span lang="en">Download via GitHub</span><span lang="de">Über GitHub herunterladen</span>
</a>
</div>
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">
<span lang="en">The extension is required to join and sync videos.</span>
<span lang="de">Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.</span>
</p>
`;
} else {
actions.innerHTML = `
<div class="join-card-actions">
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc" class="btn btn-primary">
<img src="assets/chrome.svg" alt="Chrome" width="20" style="display: block;">
<span lang="en">GET IT ON CHROME WEBSTORE</span><span lang="de">IM CHROME WEB STORE HERUNTERLADEN</span>
</a>
<a href="https://github.com/shik3i/KoalaSync" target="_blank" class="btn btn-secondary">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<span lang="en">Download via GitHub</span><span lang="de">Über GitHub herunterladen</span>
</a>
</div>
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">
<span lang="en">The extension is required to join and sync videos.</span>
<span lang="de">Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.</span>
</p>
`;
}
} else {
actions.innerHTML = `
<div class="joining-spinner" style="text-align:center; padding: 1rem;">
<div class="join-spinner"></div>
<div style="font-weight: 600; color: var(--accent);">
<span lang="en">Joining room automatically...</span><span lang="de">Raum wird automatisch betreten...</span>
</div>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
<span lang="en">Your extension is taking care of it.</span><span lang="de">Deine Erweiterung kümmert sich darum.</span>
</p>
</div>
`;
// AUTO-TRIGGER JOIN
setTimeout(() => {
window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', {
detail: {
roomId,
password,
useCustomServer: serverFlag === '1',
serverUrl: serverUrl
}
}));
}, 500);
}
}
} else {
// Fallback banner for index.html
if (!document.getElementById('koala-banner')) {
const banner = document.createElement('div');
banner.className = 'invite-banner';
banner.id = 'koala-banner';
const container = document.createElement('div');
container.className = 'container';
container.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
const inviteSpan = document.createElement('span');
inviteSpan.appendChild(document.createTextNode('🎫 Invitation for '));
const boldRoom = document.createElement('b');
boldRoom.textContent = roomId;
inviteSpan.appendChild(boldRoom);
inviteSpan.appendChild(document.createTextNode(' detected!'));
const joinLink = document.createElement('a');
joinLink.href = 'join' + window.location.hash;
joinLink.className = 'btn-banner';
joinLink.textContent = 'OPEN JOIN PAGE';
container.appendChild(inviteSpan);
container.appendChild(joinLink);
banner.appendChild(container);
document.body.prepend(banner);
}
}
// Global listener for Join Button
document.addEventListener('click', (e) => {
if (e.target && e.target.id === 'webJoinBtn') {
e.target.textContent = 'JOINING...';
e.target.disabled = true;
window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', {
detail: {
roomId,
password,
useCustomServer: serverFlag === '1',
serverUrl: serverUrl
}
}));
}
});
}
}
}, 600); // 600ms delay to ensure bridge.js has set the dataset
};
// Listen for status from Extension
window.addEventListener('KOALASYNC_STATUS', (e) => {
const { success, message } = e.detail;
const isJoinPage = window.location.pathname.includes('join');
if (isJoinPage) {
const icon = document.getElementById('join-status-icon');
const title = document.getElementById('join-title');
const actions = document.getElementById('join-actions');
const desc = document.getElementById('join-desc');
const ring = document.getElementById('status-ring');
if (success) {
if (ring) {
ring.classList.remove('active-pulse');
ring.style.display = 'none';
}
if (icon) {
icon.innerHTML = '<img src="assets/KoalaThumbsUp.webp" alt="Success" class="join-status-mascot">';
icon.style.transform = 'scale(1)';
}
const isDE = document.documentElement.classList.contains('lang-de');
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
// eslint-disable-next-line no-unsanitized/property -- both branches are hardcoded literals, no dynamic input
desc.innerHTML = isDE
? 'Verbunden! <br><span style="color:var(--accent); font-weight:bold;">Wähle jetzt einen Video-Tab in der Erweiterung aus.</span>'
: 'Connected! <br><span style="color:var(--accent); font-weight:bold;">Now select a video tab in the extension.</span>';
let count = 3;
const updateCountdown = () => {
if (count <= 0) {
window.close();
desc.textContent = isDE ? 'Beitritt erfolgreich! Du kannst diesen Tab jetzt manuell schließen.' : 'Joined successfully! You can close this tab manually.';
} else {
count--;
setTimeout(updateCountdown, 1000);
}
};
setTimeout(updateCountdown, 1000);
const closeLabel = isDE ? 'TAB JETZT SCHLIESSEN' : 'CLOSE TAB NOW';
// eslint-disable-next-line no-unsanitized/property -- closeLabel is a hardcoded literal, no dynamic input
actions.innerHTML = `
<div class="join-card-actions">
<button class="btn btn-success" onclick="window.close()">${closeLabel}</button>
</div>
`;
} else {
if (ring) {
ring.classList.remove('active-pulse');
ring.style.display = 'none';
}
if (icon) {
icon.innerHTML = '<img src="assets/KoalaThumbsDown.webp" alt="Error" class="join-status-mascot" onerror="this.outerHTML=\'❌\'">';
icon.style.transform = 'scale(1)';
}
const isDE = document.documentElement.classList.contains('lang-de');
title.textContent = isDE ? 'Fehler' : 'Error';
desc.textContent = isDE ? `Beitritt fehlgeschlagen: ${message}` : `Join failed: ${message}`;
const retryLabel = isDE ? 'ERNEUT VERSUCHEN' : 'TRY AGAIN';
// eslint-disable-next-line no-unsanitized/property -- retryLabel is a hardcoded literal, no dynamic input
actions.innerHTML = `
<div class="join-card-actions">
<button class="btn btn-primary" onclick="location.reload()">${retryLabel}</button>
</div>
`;
}
} else {
const banner = document.getElementById('koala-banner');
if (banner) {
if (success) {
banner.style.background = 'var(--success)';
banner.innerHTML = '<div class="container">✅ Joined! This tab will close in 2s...</div>';
setTimeout(() => window.close(), 2000);
} else {
banner.style.background = 'var(--error)';
banner.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.className = 'container';
errDiv.textContent = '❌ Error: ' + message;
banner.appendChild(errDiv);
}
}
}
});
// Version and release date are baked in at build time; only the relative
// "x days ago" needs to be computed at view time so it never goes stale.
const updateDynamicVersion = () => {
const bubble = document.querySelector('.koala-speech-bubble');
if (!bubble || !bubble.dataset.release) return;
const releaseDate = new Date(bubble.dataset.release);
if (isNaN(releaseDate)) return;
const diffMs = Date.now() - releaseDate.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMins = Math.floor(diffMs / (1000 * 60));
let relativeTimeEn;
let relativeTimeDe;
if (diffDays > 0) {
relativeTimeEn = `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
relativeTimeDe = `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
} else if (diffHours > 0) {
relativeTimeEn = `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`;
relativeTimeDe = `vor ${diffHours} ${diffHours === 1 ? 'Stunde' : 'Stunden'}`;
} else if (diffMins > 0) {
relativeTimeEn = `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`;
relativeTimeDe = `vor ${diffMins} ${diffMins === 1 ? 'Minute' : 'Minuten'}`;
} else {
relativeTimeEn = 'just now';
relativeTimeDe = 'gerade eben';
}
bubble.querySelectorAll('.bubble-ago').forEach(el => {
// Nearest [lang] ancestor is the bubble's own language wrapper span
// (not the <html> element, which also carries a lang attribute).
const langWrap = el.closest('[lang]');
const isDe = !!langWrap && langWrap.getAttribute('lang') === 'de';
el.textContent = (isDe ? relativeTimeDe : relativeTimeEn) + ' \u00b7 ';
});
};
// Extension Mockup Tab Switcher
const mockTabs = document.querySelectorAll('.mock-tab');
const mockScreens = document.querySelectorAll('.mock-screen');
mockTabs.forEach(tab => {
tab.addEventListener('click', () => {
mockTabs.forEach(t => t.classList.remove('active'));
mockScreens.forEach(s => s.classList.remove('active'));
tab.classList.add('active');
const targetId = tab.getAttribute('data-target');
const targetScreen = document.getElementById(targetId);
if (targetScreen) {
targetScreen.classList.add('active');
}
});
});
// The hero demo is a decorative, self-playing illustration; the real,
// accessible walkthrough is the #how-it-works section. Hide the fake
// extension UI from the accessibility tree and drop its controls from the
// tab order so screen readers are not confused and it does not trip
// contrast/label audits. Mouse interaction is unaffected.
const heroDemoScene = document.getElementById('hero-demo');
if (heroDemoScene) {
heroDemoScene.setAttribute('aria-hidden', 'true');
heroDemoScene.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')
.forEach(el => el.setAttribute('tabindex', '-1'));
}
// --- Hero Live Demo (two synced video tabs + extension popup) ---
// The same scene is used inline on desktop and mounted into a modal on
// mobile, so the demo behavior stays in one place.
const initHeroDemo = () => {
const scene = document.getElementById('hero-demo');
if (!scene) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const launcher = scene.querySelector('#demo-launcher');
const chip = scene.querySelector('#demo-sync-chip');
const cursor = scene.querySelector('#demo-cursor');
const hint = document.getElementById('demo-hint');
const playBtn = scene.querySelector('#demo-play-btn');
const pauseBtn = scene.querySelector('#demo-pause-btn');
const forceSyncBtn = scene.querySelector('#demo-force-sync');
const syncTabBtn = scene.querySelector('.mock-tab[data-target="mock-sync"]');
const roomEmpty = scene.querySelector('#demo-room-empty');
const roomJoined = scene.querySelector('#demo-room-joined');
const createRoomBtn = scene.querySelector('#demo-create-room');
const inviteCopyBtn = scene.querySelector('#demo-invite-copy');
const videoSelect = scene.querySelector('#demo-video-select');
const peerBs = scene.querySelectorAll('.demo-peer-b');
const inviteFly = scene.querySelector('#demo-invite-fly');
const toastB = scene.querySelector('#demo-toast-b');
if (!launcher || !chip || !cursor || !playBtn || !pauseBtn) return;
const EP_LEN = 2537; // fake 42:17 episode
const RATE = 1; // realtime: 1 wall-clock second = 1 video second
const START_T = 754; // 12:34
const tabs = {};
['a', 'b'].forEach(k => {
const root = scene.querySelector('#demo-tab-' + k);
tabs[k] = {
root,
fill: root.querySelector('.demo-progress-fill'),
time: root.querySelector('.demo-time'),
t: START_T,
playing: false
};
});
const fmt = (s) => {
s = Math.floor(s);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
};
const renderTab = (tab) => {
tab.fill.style.width = ((tab.t / EP_LEN) * 100).toFixed(2) + '%';
tab.time.textContent = fmt(tab.t);
// Frame-locked parallax offsets
const t = tab.t;
const backOffset = (t * -8) % 160;
const midOffset = (t * -24) % 160;
const foreOffset = (t * -48) % 160;
// Floating lantern: slow vertical bob + softer horizontal sway on
// detuned periods, so the drift never repeats exactly in sync
const bounceY = Math.sin((t % 5.2) / 5.2 * Math.PI * 2) * 3.5;
const lanternX = Math.cos((t % 8.6) / 8.6 * Math.PI * 2) * 4;
tab.root.style.setProperty('--scroll-back', backOffset.toFixed(2) + 'px');
tab.root.style.setProperty('--scroll-mid', midOffset.toFixed(2) + 'px');
tab.root.style.setProperty('--scroll-fore', foreOffset.toFixed(2) + 'px');
tab.root.style.setProperty('--bounce-y', bounceY.toFixed(2) + 'px');
tab.root.style.setProperty('--lantern-x', lanternX.toFixed(2) + 'px');
};
const updateSyncUI = () => {
scene.classList.toggle('streaming', tabs.a.playing && tabs.b.playing);
};
// Playback clock (runs only while at least one tab is playing)
let rafId = null;
let lastTs = null;
const tick = (ts) => {
rafId = null;
const dt = lastTs === null ? 0 : Math.min((ts - lastTs) / 1000, 0.5);
lastTs = ts;
let anyPlaying = false;
['a', 'b'].forEach(k => {
const tab = tabs[k];
if (!tab.playing) return;
anyPlaying = true;
tab.t = (tab.t + dt * RATE) % EP_LEN;
renderTab(tab);
});
updateSyncUI();
if (anyPlaying) rafId = requestAnimationFrame(tick);
else lastTs = null;
};
const ensureLoop = () => {
if (rafId === null && (tabs.a.playing || tabs.b.playing)) {
lastTs = null;
rafId = requestAnimationFrame(tick);
}
};
const setPlaying = (k, playing) => {
const tab = tabs[k];
tab.playing = playing;
tab.root.classList.toggle('playing', playing);
tab.root.setAttribute('aria-pressed', playing ? 'true' : 'false');
ensureLoop();
updateSyncUI();
};
const sceneLocalPoint = (rect, fx, fy) => {
const sceneRect = scene.getBoundingClientRect();
const scaleX = scene.offsetWidth ? sceneRect.width / scene.offsetWidth : 1;
const scaleY = scene.offsetHeight ? sceneRect.height / scene.offsetHeight : scaleX;
const safeScaleX = scaleX || 1;
const safeScaleY = scaleY || safeScaleX;
const xFactor = typeof fx === 'number' ? fx : 0.5;
const yFactor = typeof fy === 'number' ? fy : 0.5;
return {
x: (rect.left - sceneRect.left) / safeScaleX + (rect.width / safeScaleX) * xFactor,
y: (rect.top - sceneRect.top) / safeScaleY + (rect.height / safeScaleY) * yFactor
};
};
const pulse = (key) => {
restartAnimation(tabs[key].root, 'sync-pulse');
};
const NAMES = { a: '🐱 ChillCat', b: '🐶 HappyDog' };
const chipEvent = chip.querySelector('.demo-chip-event');
let eventTimer = null;
const showEvent = (text) => {
if (!chipEvent) return;
chipEvent.textContent = text;
chip.classList.add('event');
clearTimeout(eventTimer);
eventTimer = setTimeout(() => chip.classList.remove('event'), 1600);
};
// Core of the demo: play/pause on EITHER side is broadcast to the peer
// tab — exactly what the extension does. The popup is just a remote
// control on top, never a requirement.
let broadcasting = false;
const broadcast = (sourceKey, playing) => {
if (broadcasting) return;
broadcasting = true;
const peerKey = sourceKey === 'a' ? 'b' : 'a';
setPlaying(sourceKey, playing);
showEvent((playing ? '▶ ' : '❚❚ ') + NAMES[sourceKey]);
// the peer follows near-instantly — that is the whole point
setTimeout(() => {
tabs[peerKey].t = tabs[sourceKey].t;
setPlaying(peerKey, playing);
renderTab(tabs[peerKey]);
pulse(peerKey);
broadcasting = false;
}, 90);
};
// Seeking works the same way: scrub one tab, the peer jumps along
const seekTo = (sourceKey, fraction) => {
if (broadcasting) return;
broadcasting = true;
const peerKey = sourceKey === 'a' ? 'b' : 'a';
tabs[sourceKey].t = fraction * EP_LEN;
renderTab(tabs[sourceKey]);
showEvent('» ' + NAMES[sourceKey]);
// visible "cut" so the jump reads as a real seek, not a silent update
flashSeek(tabs[sourceKey].root);
setTimeout(() => {
tabs[peerKey].t = tabs[sourceKey].t;
renderTab(tabs[peerKey]);
flashSeek(tabs[peerKey].root);
pulse(peerKey);
broadcasting = false;
}, 90);
};
// Restart the film animations mid-stride + trigger the sweep overlay
let seekFlashTimers = [];
const flashSeek = (root) => {
if (!root) return;
// Restart every animated layer from frame zero so the jump is visible
const film = root.querySelector('.demo-film');
if (film) {
film.classList.add('demo-reset');
requestAnimationFrame(() => {
film.classList.remove('demo-reset');
});
}
restartAnimation(root, 'demo-seeking');
const t = setTimeout(() => root.classList.remove('demo-seeking'), 360);
seekFlashTimers.push(t);
};
const setPopupOpen = (open) => {
scene.classList.toggle('popup-open', open);
launcher.setAttribute('aria-expanded', open ? 'true' : 'false');
};
// Manual controls (the automated walkthrough drives these same handlers)
launcher.addEventListener('click', () => setPopupOpen(!scene.classList.contains('popup-open')));
// The popup remote control acts as 🐱 ChillCat (tab A's user)
playBtn.addEventListener('click', () => {
if (tabs.a.playing && tabs.b.playing) { pulse('b'); return; }
broadcast('a', true);
});
pauseBtn.addEventListener('click', () => {
if (!tabs.a.playing && !tabs.b.playing) { pulse('b'); return; }
broadcast('a', false);
});
if (forceSyncBtn) forceSyncBtn.addEventListener('click', () => {
tabs.b.t = tabs.a.t = Math.max(tabs.a.t, tabs.b.t);
renderTab(tabs.a);
renderTab(tabs.b);
pulse('a');
pulse('b');
});
['a', 'b'].forEach(k => {
const root = tabs[k].root;
const toggle = () => broadcast(k, !tabs[k].playing);
// the toolbar extension icon lives inside card A — its clicks
// toggle the popup, not playback
root.addEventListener('click', (e) => {
if (e.target.closest('.demo-ext-launcher')) return;
toggle();
});
root.addEventListener('keydown', (e) => {
if (e.target.closest('.demo-ext-launcher')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
const progress = root.querySelector('.demo-progress');
if (progress) {
progress.addEventListener('click', (e) => {
e.stopPropagation(); // a scrub must not toggle play/pause
const r = progress.getBoundingClientRect();
seekTo(k, Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1));
});
}
});
chip.addEventListener('click', () => {
pulse('a');
pulse('b');
});
// --- Story state (room creation -> invite -> connected) ---
const setRoomJoined = (joined) => {
if (roomEmpty) roomEmpty.style.display = joined ? 'none' : 'flex';
if (roomJoined) roomJoined.style.display = joined ? '' : 'none';
};
const setConnected = (connected) => {
scene.classList.toggle('connected', connected);
peerBs.forEach(el => { el.style.display = connected ? '' : 'none'; });
};
let toastTimer = null;
const showToastB = (text) => {
if (!toastB || !text) return;
toastB.textContent = '✓ ' + text;
toastB.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastB.classList.remove('show'), 1700);
};
// The invite link visibly travels from the popup to the friend's window
const flyInvite = () => new Promise((resolve) => {
if (!inviteFly || !inviteCopyBtn) { resolve(); return; }
const from = inviteCopyBtn.getBoundingClientRect();
const to = tabs.b.root.querySelector('.demo-tab-titlebar').getBoundingClientRect();
const fromPoint = sceneLocalPoint(from, 0, 0);
const toPoint = sceneLocalPoint(to, 0.5, 0);
inviteFly.style.transition = 'none';
inviteFly.style.left = fromPoint.x + 'px';
inviteFly.style.top = fromPoint.y + 'px';
inviteFly.style.opacity = '1';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
inviteFly.style.transition = '';
inviteFly.style.left = (toPoint.x - 40) + 'px';
inviteFly.style.top = (toPoint.y + 2) + 'px';
});
});
setTimeout(() => {
inviteFly.style.opacity = '0';
resolve();
}, 800);
});
const flashSelect = () => {
if (!videoSelect) return;
// Actually pick the Stranger Things tab so the placeholder reads as chosen
if (videoSelect.options.length > 1) {
videoSelect.selectedIndex = 1;
}
restartAnimation(videoSelect, 'demo-attn');
};
if (createRoomBtn) createRoomBtn.addEventListener('click', () => setRoomJoined(true));
renderTab(tabs.a);
renderTab(tabs.b);
updateSyncUI();
// JS is active: start with the popup tucked away and, if the scripted
// story is going to run, rewind to the "before" state (no transitions
// on load).
scene.classList.add('demo-no-anim');
if (!reduceMotion) {
setPopupOpen(false);
setConnected(false);
setRoomJoined(false);
}
requestAnimationFrame(() => {
scene.classList.remove('demo-no-anim');
});
const showHint = () => {
if (hint) hint.classList.add('show');
};
// Automated walkthrough; any interaction aborts it and jumps
// straight to the finished end state, then the user's click applies.
let userTookOver = false;
let demoStarted = false;
let demoFinished = false;
let demoRunId = 0;
const resetDemo = () => {
demoStarted = false;
demoFinished = false;
userTookOver = false;
demoRunId++;
// Reset DOM states:
scene.classList.add('demo-no-anim');
setPopupOpen(false);
setConnected(false);
setRoomJoined(false);
cursor.classList.remove('visible', 'clicking');
scene.classList.remove('demo-complete', 'streaming');
if (hint) hint.classList.remove('show');
// Rewind tab playback times to START_T, paused:
['a', 'b'].forEach(k => {
const tab = tabs[k];
tab.t = START_T;
setPlaying(k, false);
renderTab(tab);
});
// Clear any sync-pulse or seeking classes:
['a', 'b'].forEach(k => {
tabs[k].root.classList.remove('sync-pulse', 'demo-seeking');
});
// Clean up any pending flash timers:
seekFlashTimers.forEach(clearTimeout);
seekFlashTimers = [];
// Remove demo-no-anim on next frame:
requestAnimationFrame(() => {
scene.classList.remove('demo-no-anim');
});
};
const finishDemo = () => {
if (demoFinished) return;
demoFinished = true;
setRoomJoined(true);
setConnected(true);
cursor.classList.remove('visible');
scene.classList.add('demo-complete');
showHint();
};
const takeOver = (e) => {
userTookOver = true;
finishDemo();
// Clicks outside the popup (and not on its launcher icon) always collapse the popup
if (e && e.target &&
!e.target.closest('.extension-mockup') &&
!e.target.closest('.demo-ext-launcher')) {
setPopupOpen(false);
}
};
scene.addEventListener('pointerdown', takeOver, true);
scene.addEventListener('keydown', takeOver, true);
// Clicking anywhere else on the page also closes the popup, like a
// real browser extension popup would.
document.addEventListener('pointerdown', (e) => {
if (!scene.classList.contains('popup-open')) return;
if (scene.contains(e.target)) return; // in-scene clicks are handled by takeOver
userTookOver = true;
finishDemo();
setPopupOpen(false);
}, true);
// Explicit close button in the popup header
const popupCloseBtn = scene.querySelector('#demo-popup-close');
if (popupCloseBtn) {
popupCloseBtn.addEventListener('click', () => {
userTookOver = true;
finishDemo();
setPopupOpen(false);
});
}
// Escape closes the popup as well
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!scene.classList.contains('popup-open')) return;
userTookOver = true;
finishDemo();
setPopupOpen(false);
});
const runDemo = async () => {
if (demoStarted || userTookOver || reduceMotion) return;
// Prevent inline demo animations from running on tablet/mobile if windows are hidden:
if (window.innerWidth < 1024 && !scene.closest('.mobile-demo-stage')) {
finishDemo();
return;
}
demoRunId++;
const myRunId = demoRunId;
demoStarted = true;
const wait = (ms) => new Promise(r => setTimeout(r, ms));
const cursorHotspot = { x: 5, y: 2 };
const moveTo = async (el, fx) => {
if (demoRunId !== myRunId) return;
const er = el.getBoundingClientRect();
const point = sceneLocalPoint(er, fx, 0.5);
cursor.style.left = (point.x - cursorHotspot.x) + 'px';
cursor.style.top = (point.y - cursorHotspot.y) + 'px';
await wait(700);
};
const step = async (el, pause, action, fx) => {
if (userTookOver || demoRunId !== myRunId || !el) return false;
await moveTo(el, fx);
if (userTookOver || demoRunId !== myRunId) return false;
restartAnimation(cursor, 'clicking');
await wait(180);
if (userTookOver || demoRunId !== myRunId) return false;
if (action) action(); else el.click();
// The scripted cursor's clicks scatter leaves like real ones
burstFromElement(el);
await wait(320 + (pause || 0));
return !userTookOver && demoRunId === myRunId;
};
await wait(500);
if (userTookOver || demoRunId !== myRunId) return;
cursor.classList.add('visible');
await wait(400);
if (userTookOver || demoRunId !== myRunId) return;
const progressA = tabs.a.root.querySelector('.demo-progress');
// Act 1: open the extension and create a room
if (!await step(launcher, 300)) return;
if (demoRunId !== myRunId) return;
if (!await step(createRoomBtn, 500)) return;
if (demoRunId !== myRunId) return;
// Act 2: the invite link travels to the friend's browser
if (!await step(inviteCopyBtn, 0)) return;
if (demoRunId !== myRunId) return;
await flyInvite();
if (userTookOver || demoRunId !== myRunId) return;
setConnected(true);
showToastB(toastB ? toastB.dataset.joined : '');
pulse('b');
await wait(900);
if (userTookOver || demoRunId !== myRunId) return;
// Act 3: both sides pick the video tab to sync
if (!await step(syncTabBtn, 250)) return;
if (demoRunId !== myRunId) return;
if (!await step(videoSelect, 150, flashSelect)) return;
if (demoRunId !== myRunId) return;
showToastB(toastB ? toastB.dataset.selected : '');
await wait(700);
if (userTookOver || demoRunId !== myRunId) return;
// Act 4: play for everyone, then tuck the popup away
if (!await step(playBtn, 1800)) return;
if (demoRunId !== myRunId) return;
if (!await step(launcher, 500)) return;
if (demoRunId !== myRunId) return;
// Act 5: any side can control — pause there, seek here, play again
if (!await step(tabs.b.root, 1000)) return;
if (demoRunId !== myRunId) return;
if (!await step(progressA, 1300, () => seekTo('a', 0.62), 0.62)) return;
if (demoRunId !== myRunId) return;
if (!await step(tabs.a.root, 700)) return;
if (demoRunId !== myRunId) return;
finishDemo();
};
scene.__koalaStartDemo = reduceMotion ? finishDemo : runDemo;
scene.__koalaFinishDemo = finishDemo;
scene.__koalaResetDemo = resetDemo;
if ('IntersectionObserver' in window && !reduceMotion) {
const demoObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
demoObserver.disconnect();
setTimeout(runDemo, 900);
}
});
}, { threshold: 0.45 });
demoObserver.observe(scene);
} else {
finishDemo();
}
};
initHeroDemo();
const initMobileDemoModal = () => {
const openBtn = document.getElementById('mobile-demo-open');
const modal = document.getElementById('mobile-demo-modal');
const stage = document.getElementById('mobile-demo-stage');
const closeBtn = document.getElementById('mobile-demo-close');
const wrapper = document.querySelector('.hero-grid > .hero-mockup-wrapper');
if (!openBtn || !modal || !stage || !closeBtn || !wrapper) return;
const originalParent = wrapper.parentNode;
const originalNext = wrapper.nextSibling;
let previousFocus = null;
const openModal = () => {
previousFocus = document.activeElement && typeof document.activeElement.focus === 'function'
? document.activeElement
: null;
modal.hidden = false;
document.body.classList.add('mobile-demo-open');
openBtn.setAttribute('aria-expanded', 'true');
stage.appendChild(wrapper);
requestAnimationFrame(() => {
closeBtn.focus({ preventScroll: true });
const scene = wrapper.querySelector('#hero-demo');
if (scene) {
if (typeof scene.__koalaResetDemo === 'function') {
scene.__koalaResetDemo();
}
if (typeof scene.__koalaStartDemo === 'function') {
scene.__koalaStartDemo();
}
}
});
};
const closeModal = () => {
if (modal.hidden) return;
modal.hidden = true;
document.body.classList.remove('mobile-demo-open');
openBtn.setAttribute('aria-expanded', 'false');
const scene = wrapper.querySelector('#hero-demo');
if (scene && typeof scene.__koalaResetDemo === 'function') {
scene.__koalaResetDemo();
}
if (originalNext && originalNext.parentNode === originalParent) {
originalParent.insertBefore(wrapper, originalNext);
} else {
originalParent.appendChild(wrapper);
}
if (previousFocus && typeof previousFocus.focus === 'function') {
previousFocus.focus({ preventScroll: true });
}
};
openBtn.setAttribute('aria-expanded', 'false');
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
modal.querySelectorAll('[data-mobile-demo-close]').forEach(el => {
el.addEventListener('click', closeModal);
});
document.addEventListener('keydown', (e) => {
if (modal.hidden) return;
if (e.key === 'Escape') closeModal();
if (e.key === 'Tab') {
e.preventDefault();
closeBtn.focus({ preventScroll: true });
}
});
};
initMobileDemoModal();
// Terminal Tab Switcher
const termTabBtns = document.querySelectorAll('.terminal-tab-btn');
const termPanes = document.querySelectorAll('.terminal-pane');
termTabBtns.forEach(btn => {
btn.addEventListener('click', () => {
termTabBtns.forEach(b => b.classList.remove('active'));
termPanes.forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const targetPaneId = btn.getAttribute('data-tab');
const targetPane = document.getElementById(targetPaneId);
if (targetPane) {
targetPane.classList.add('active');
}
});
});
// Terminal Clipboard Copy
const copyBtn = document.querySelector('.terminal-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const activePane = document.querySelector('.terminal-pane.active');
if (!activePane) return;
const codeElement = activePane.querySelector('code');
if (!codeElement) return;
const textToCopy = codeElement.innerText || codeElement.textContent;
navigator.clipboard.writeText(textToCopy).then(() => {
const isDE = document.documentElement.classList.contains('lang-de');
const originalHTML = copyBtn.innerHTML;
// eslint-disable-next-line no-unsanitized/property -- both branches are hardcoded literals, no dynamic input
copyBtn.innerHTML = isDE ? '✅ Kopiert!' : '✅ Copied!';
copyBtn.disabled = true;
setTimeout(() => {
// eslint-disable-next-line no-unsanitized/property -- restores markup captured from this same button above
copyBtn.innerHTML = originalHTML;
copyBtn.disabled = false;
}, 2000);
}).catch(err => {
console.error('Failed to copy text: ', err);
});
});
}
// Mobile Hamburger Menu Toggle
const hamburger = document.querySelector('.hamburger');
const navLinks = document.querySelector('#primary-nav');
if (hamburger && navLinks) {
hamburger.setAttribute('aria-expanded', 'false');
const open = () => {
navLinks.classList.add('open');
hamburger.setAttribute('aria-expanded', 'true');
document.addEventListener('keydown', onEsc);
};
const close = () => {
navLinks.classList.remove('open');
hamburger.setAttribute('aria-expanded', 'false');
document.removeEventListener('keydown', onEsc);
};
const toggle = () => navLinks.classList.contains('open') ? close() : open();
const onEsc = (e) => { if (e.key === 'Escape') close(); };
hamburger.addEventListener('click', toggle);
navLinks.querySelectorAll('a').forEach(a => a.addEventListener('click', close));
}
// Dynamically localize home links on root dynamic pages (impressum, datenschutz, join)
const localizeHomeLinks = () => {
const activeLang = safeGetLocalStorage('koala_lang') || (navigator.language.startsWith('de') ? 'de' : 'en');
const path = window.location.pathname;
const pathSegments = path.split('/');
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'].includes(seg));
// Only need to do this dynamic rewrite if we are NOT already inside a localized subdirectory
if (!isSubdir) {
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="it/"], a[href="nl/"], a[href="pl/"], a[href="pt/"], a[href="pt-BR/"], a[href="tr/"], a[href="ru/"], a[href="ja/"], a[href="ko/"], a[href="zh/"], a[href="uk/"]');
homeLinks.forEach(link => {
link.href = (activeLang === 'en') ? './' : `${activeLang}/`;
});
const altLinks = document.querySelectorAll('a[href*="alternatives"]');
altLinks.forEach(link => {
const attr = link.getAttribute('href');
if (attr === 'alternatives' || attr.startsWith('alternatives/') || attr.endsWith('/alternatives')) {
link.href = (activeLang === 'en') ? attr : `${activeLang}/${attr}`;
}
});
}
};
// Modern Language Selector Navigation and State Toggling
const handleLanguageChange = (e) => {
const select = e.currentTarget;
const newLang = select.value;
const path = window.location.pathname;
// Save the user's preference
safeSetLocalStorage('koala_lang', newLang);
const isLegalImprint = path.includes('impressum') || path.includes('imprint');
const isLegalPrivacy = path.includes('datenschutz') || path.includes('privacy');
if (isLegalImprint) {
let target;
const hasHtml = path.endsWith('.html');
if (newLang === 'de') {
target = hasHtml ? 'de/impressum.html' : 'de/impressum';
if (path.includes('/de/')) target = hasHtml ? 'impressum.html' : 'impressum';
} else {
target = hasHtml ? 'imprint.html' : 'imprint';
if (path.includes('/de/')) target = hasHtml ? '../imprint.html' : '../imprint';
}
window.location.href = target + window.location.hash;
return;
} else if (isLegalPrivacy) {
let target;
const hasHtml = path.endsWith('.html');
if (newLang === 'de') {
target = hasHtml ? 'de/datenschutz.html' : 'de/datenschutz';
if (path.includes('/de/')) target = hasHtml ? 'datenschutz.html' : 'datenschutz';
} else {
target = hasHtml ? 'privacy.html' : 'privacy';
if (path.includes('/de/')) target = hasHtml ? '../privacy.html' : '../privacy';
}
window.location.href = target + window.location.hash;
return;
}
const pathSegments = path.split('/');
const isAlternative = pathSegments.includes('alternatives');
if (isAlternative) {
let target;
const firstSeg = pathSegments[1];
const isLangSubdir = ['de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'].includes(firstSeg);
if (newLang === 'en') {
if (isLangSubdir) {
pathSegments.splice(1, 1);
}
} else {
if (isLangSubdir) {
pathSegments[1] = newLang;
} else {
pathSegments.splice(1, 0, newLang);
}
}
target = pathSegments.join('/');
window.location.href = target + window.location.hash;
return;
}
// Determine if we are on a static landing page versus a dynamic utility page
const isIndex = !path.includes('join');
if (isIndex) {
// Static navigation: Route to correct subdirectory
const pathSegments = path.split('/');
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'it', 'nl', 'pl', 'pt', 'pt-BR', 'tr', 'ru', 'ja', 'ko', 'zh', 'uk'].includes(seg));
let targetPath;
if (newLang === 'en') {
if (isSubdir) {
targetPath = '../';
} else {
targetPath = './';
}
} else {
if (isSubdir) {
// Switching from one language subdirectory to another (e.g., /de/ to /fr/)
targetPath = '../' + newLang + '/';
} else {
// Switching from root (English) to a language subdirectory (e.g., / to /fr/)
targetPath = newLang + '/';
}
}
window.location.href = targetPath + window.location.hash;
} else {
// Dynamic page: Toggle classes and update elements dynamically without navigating away
const html = document.documentElement;
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-it', 'lang-nl', 'lang-pl', 'lang-pt', 'lang-pt-br', 'lang-tr', 'lang-ru', 'lang-ja', 'lang-ko', 'lang-zh', 'lang-uk');
// Fallback dynamic pages to 'en' if 'de' is not chosen (since fr/es markup is not present)
const activeDisplayLang = (newLang === 'de') ? 'de' : 'en';
html.classList.add('lang-' + activeDisplayLang);
html.lang = activeDisplayLang;
// Sync all selects on the page to the new value
document.querySelectorAll('.lang-dropdown').forEach(sel => {
sel.value = newLang;
});
// Update titles dynamically
const isJoin = path.includes('join');
if (isJoin) {
const titles = { en: 'Join Room | KoalaSync', de: 'Raum beitreten | KoalaSync' };
document.title = titles[activeDisplayLang] || titles.en;
}
// Localize home links dynamically
localizeHomeLinks();
}
};
// Dynamically adjust language select width to fit the selected option's text length
const adjustDropdownWidth = () => {
document.querySelectorAll('.lang-dropdown').forEach(select => {
const tempSpan = document.createElement('span');
const style = window.getComputedStyle(select);
tempSpan.style.fontFamily = style.fontFamily;
tempSpan.style.fontSize = style.fontSize;
tempSpan.style.fontWeight = style.fontWeight;
tempSpan.style.visibility = 'hidden';
tempSpan.style.position = 'absolute';
tempSpan.style.whiteSpace = 'nowrap';
const activeOption = select.options[select.selectedIndex];
if (activeOption) {
tempSpan.textContent = activeOption.textContent;
document.body.appendChild(tempSpan);
const textWidth = tempSpan.getBoundingClientRect().width;
select.style.width = (textWidth + 18) + 'px';
document.body.removeChild(tempSpan);
}
});
};
// Register change event listener for the dropdowns
document.querySelectorAll('.lang-dropdown').forEach(select => {
select.addEventListener('change', (e) => {
handleLanguageChange(e);
adjustDropdownWidth();
});
});
// Initialize language select elements to show the current preferred language
const initLanguageSelectorValue = () => {
const savedLang = safeGetLocalStorage('koala_lang');
const browserLang = navigator.language.startsWith('de') ? 'de' : 'en';
const activePref = savedLang || browserLang;
document.querySelectorAll('.lang-dropdown').forEach(select => {
select.value = activePref;
});
adjustDropdownWidth();
};
// Impressum Email Obfuscation Click Reveal
document.querySelectorAll('.email-reveal').forEach(el => {
el.addEventListener('click', function() {
const user = this.getAttribute('data-user');
const domain = this.getAttribute('data-domain');
if (user && domain) {
this.textContent = `${user}@${domain}`;
}
});
});
// Automated Store/Local Badge Linking based on User-Agent
const detectBrowserAndElevateBadge = () => {
const isFirefox = navigator.userAgent.includes('Firefox');
const isChrome = navigator.userAgent.includes('Chrome') || navigator.userAgent.includes('Chromium');
const chromeBtns = document.querySelectorAll('.btn-primary');
const firefoxBtns = document.querySelectorAll('.btn-firefox');
if (isFirefox && chromeBtns.length > 0 && firefoxBtns.length > 0) {
// User is on Firefox: Elevate Firefox button to primary, make Chrome secondary
chromeBtns.forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
});
firefoxBtns.forEach(btn => {
// Put Firefox first in visual order
btn.style.order = '-1';
// Add subtle focus scale effect
btn.style.transform = 'scale(1.05)';
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
});
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.05) translateY(-2px)';
});
});
} else if (isChrome && chromeBtns.length > 0 && firefoxBtns.length > 0) {
// User is on Chrome: Make Firefox secondary
firefoxBtns.forEach(btn => {
btn.classList.remove('btn-firefox');
btn.classList.add('btn-secondary', 'btn-firefox-secondary');
btn.style.color = 'var(--text)';
btn.style.background = 'var(--card)';
btn.style.border = '1px solid var(--glass-border)';
btn.style.boxShadow = 'none';
});
}
// Handle Step 1 Landing Page Download Badges & Nav Badge
setTimeout(() => {
const isInstalled = document.documentElement.dataset.koalasyncInstalled === 'true';
// Nav Badge Logic
const navBadge = document.getElementById('nav-extension-status');
if (isInstalled && navBadge) {
navBadge.style.display = 'inline-flex';
}
const illusChrome = document.querySelectorAll('.illus-store-btn.chrome');
const illusFirefox = document.querySelectorAll('.illus-store-btn.firefox');
if (isFirefox && illusFirefox.length > 0) {
illusFirefox.forEach(btn => {
btn.style.order = '-1';
if (!isInstalled) {
btn.classList.add('install-breathe');
btn.style.cursor = 'pointer';
btn.onclick = () => window.open('https://addons.mozilla.org/de/firefox/addon/koalasync/', '_blank', 'noopener');
}
});
illusChrome.forEach(btn => {
btn.style.opacity = '0.5';
btn.style.transform = 'scale(0.95)';
});
} else if (isChrome && illusChrome.length > 0) {
illusChrome.forEach(btn => {
btn.style.order = '-1';
if (!isInstalled) {
btn.classList.add('install-breathe');
btn.style.cursor = 'pointer';
btn.onclick = () => window.open('https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc', '_blank', 'noopener');
}
});
illusFirefox.forEach(btn => {
btn.style.opacity = '0.5';
btn.style.transform = 'scale(0.95)';
});
}
// Pulse main hero CTA buttons via Web Animations API
// (avoids CSS transition/inline-style conflicts from mouse handlers)
if (!isInstalled) {
const heroBtns = document.querySelectorAll(isFirefox ? '.btn-firefox' : (isChrome ? '.btn-primary' : null));
if (heroBtns && heroBtns.length > 0) {
heroBtns.forEach(btn => {
const isFF = btn.classList.contains('btn-firefox');
const glowColor = isFF ? 'rgba(201, 103, 54, ' : 'rgba(86, 174, 108, ';
btn.animate([
{ transform: 'scale(1)', boxShadow: `0 0 15px ${glowColor}0.2)` },
{ transform: 'scale(1.05)', boxShadow: `0 0 25px ${glowColor}0.5)` },
{ transform: 'scale(1)', boxShadow: `0 0 15px ${glowColor}0.2)` }
], {
duration: 2500,
iterations: Infinity,
easing: 'ease-in-out'
});
});
}
}
}, 600);
};
detectBrowserAndElevateBadge();
checkInvite();
updateDynamicVersion();
localizeHomeLinks();
initLanguageSelectorValue();
// FAQ Accordion Transition (Web Animations API for smooth vertical spring height collapse/expand)
try {
document.querySelectorAll('.faq-item').forEach(details => {
const summary = details.querySelector('summary');
let isAnimating = false;
summary.addEventListener('click', (e) => {
e.preventDefault();
if (isAnimating) return;
const startHeight = details.offsetHeight;
if (details.hasAttribute('open')) {
isAnimating = true;
const computed = window.getComputedStyle(details);
const padY = parseInt(computed.paddingTop) + parseInt(computed.paddingBottom);
const borderY = parseInt(computed.borderTopWidth) + parseInt(computed.borderBottomWidth);
const closedHeight = summary.offsetHeight + padY + (isNaN(borderY) ? 0 : borderY);
const animation = details.animate([
{ height: startHeight + 'px' },
{ height: closedHeight + 'px' }
], {
duration: 250,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)'
});
animation.onfinish = () => {
details.removeAttribute('open');
details.style.height = '';
isAnimating = false;
};
} else {
isAnimating = true;
details.setAttribute('open', '');
const openHeight = details.offsetHeight;
const computed = window.getComputedStyle(details);
const padY = parseInt(computed.paddingTop) + parseInt(computed.paddingBottom);
const borderY = parseInt(computed.borderTopWidth) + parseInt(computed.borderBottomWidth);
const closedHeight = summary.offsetHeight + padY + (isNaN(borderY) ? 0 : borderY);
const animation = details.animate([
{ height: closedHeight + 'px' },
{ height: openHeight + 'px' }
], {
duration: 250,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)'
});
animation.onfinish = () => {
details.style.height = '';
isAnimating = false;
};
}
});
});
} catch (err) {
console.warn(err);
}
});