// 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 = `
Simulating connection (DEV)...Verbindung wird simuliert (DEV)...

Simulating status event in 1.5 seconds.Status-Event wird in 1,5 Sekunden simuliert.

`; 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 = `
Firefox GET IT ON MOZILLA ADD-ONSIM FIREFOX ADD-ON STORE HERUNTERLADEN Download via GitHubÜber GitHub herunterladen

The extension is required to join and sync videos. Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.

`; } else { actions.innerHTML = `
Chrome GET IT ON CHROME WEBSTOREIM CHROME WEB STORE HERUNTERLADEN Download via GitHubÜber GitHub herunterladen

The extension is required to join and sync videos. Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.

`; } } else { actions.innerHTML = `
Joining room automatically...Raum wird automatisch betreten...

Your extension is taking care of it.Deine Erweiterung kümmert sich darum.

`; // 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 = 'Success'; 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!
Wähle jetzt einen Video-Tab in der Erweiterung aus.' : 'Connected!
Now select a video tab in the extension.'; 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 = `
`; } else { if (ring) { ring.classList.remove('active-pulse'); ring.style.display = 'none'; } if (icon) { icon.innerHTML = 'Error'; 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 = `
`; } } else { const banner = document.getElementById('koala-banner'); if (banner) { if (success) { banner.style.background = 'var(--success)'; banner.innerHTML = '
✅ Joined! This tab will close in 2s...
'; 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 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); } });