mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
e9d735cd39
# Conflicts: # extension/background.js # extension/popup.js
1776 lines
82 KiB
JavaScript
1776 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';
|
|
|
|
const invite = globalThis.KoalaSyncInviteLinks?.parseInviteHash(window.location.hash);
|
|
if (invite) {
|
|
const { roomId, password, chatKey, useCustomServer, serverUrl } = invite;
|
|
|
|
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,
|
|
chatKey,
|
|
useCustomServer,
|
|
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,
|
|
chatKey,
|
|
useCustomServer,
|
|
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);
|
|
}
|
|
});
|