Files
KoalaSync/website/app.js
T
Timo 14c9ab1d5e fix: Ensure hero heading has minimum distance from fixed nav bar across all languages
- Change .hero from align-items:center to flex-start so content starts from top
- Increase padding-top in short-viewport media query (<=920px height) to
  clamp(7.5rem, 10vh, 9rem) from clamp(3.75rem, 6vh, 5.5rem) so it always
  clears the ~96px nav bar
- Fix pre-existing lint errors: remove unused variables demoFinishedByStory,
  firstTake; add website/www to eslint ignores
2026-07-03 14:02:47 +02:00

1306 lines
60 KiB
JavaScript

// KoalaSync Landing Page Logic
document.addEventListener('DOMContentLoaded', () => {
// 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;
}
};
// Scroll Reveal Logic (IntersectionObserver for performance)
const revealElements = document.querySelectorAll('[data-reveal]');
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 -30px 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) {
history.replaceState(null, null, '#' + entry.target.id);
}
});
}, { threshold: 0.3 });
document.querySelectorAll('section[id], header[id]').forEach(el => sectionObserver.observe(el));
}
// Navbar scroll effect
const nav = document.querySelector('nav');
window.addEventListener('scroll', () => {
if (window.scrollY > 50) {
nav.style.padding = '0.75rem 0';
nav.style.background = 'rgba(15, 23, 42, 0.9)';
} else {
nav.style.padding = '1rem 0';
nav.style.background = 'rgba(30, 41, 59, 0.7)';
}
});
// Invite Detection & Bridge
const checkInvite = () => {
const isJoinPage = window.location.pathname.includes('join');
// Dev Simulation Mode via URL Search Parameter (?dev=success) or Hash (#dev=success / #devsuccess)
const urlParams = new URLSearchParams(window.location.search);
let devMode = urlParams.get('dev');
if (!devMode) {
const hashClean = window.location.hash.startsWith('#') ? window.location.hash.substring(1) : window.location.hash;
const hashParams = new URLSearchParams(hashClean);
devMode = hashParams.get('dev');
}
if (!devMode) {
if (window.location.hash.includes('devsuccess') || window.location.search.includes('devsuccess')) devMode = 'success';
if (window.location.hash.includes('devfailure') || window.location.search.includes('devfailure')) devMode = 'failure';
}
if (isJoinPage && devMode) {
setTimeout(() => {
const displayRoom = document.getElementById('display-room-id');
const actions = document.getElementById('join-actions');
if (displayRoom) displayRoom.textContent = 'DEV-ROOM';
if (actions) {
actions.innerHTML = `
<div class="joining-spinner" style="text-align:center; padding: 1rem;">
<div class="join-spinner"></div>
<div style="font-weight: 600; color: var(--accent);">
<span lang="en">Simulating connection (DEV)...</span><span lang="de">Verbindung wird simuliert (DEV)...</span>
</div>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
<span lang="en">Simulating status event in 1.5 seconds.</span><span lang="de">Status-Event wird in 1,5 Sekunden simuliert.</span>
</p>
</div>
`;
setTimeout(() => {
window.dispatchEvent(new CustomEvent('KOALASYNC_STATUS', {
detail: {
success: devMode === 'success',
message: devMode === 'failure' ? 'Simulated Connection Timeout!' : ''
}
}));
}, 1500);
}
}, 600);
return;
}
// Use a short timeout to let the bridge script initialize its dataset attribute
setTimeout(() => {
const isInstalled = document.documentElement.dataset.koalasyncInstalled === 'true';
if (window.location.hash.startsWith('#join:')) {
const parts = window.location.hash.split(':');
if (parts.length >= 3) {
const roomId = parts[1];
const password = parts[2];
const serverFlag = parts[3] || '0';
const serverUrl = parts[4] ? decodeURIComponent(parts[4]) : '';
if (isJoinPage) {
const displayRoom = document.getElementById('display-room-id');
const actions = document.getElementById('join-actions');
if (displayRoom) displayRoom.textContent = roomId;
if (actions) {
if (!isInstalled) {
const isFirefox = navigator.userAgent.includes('Firefox');
if (isFirefox) {
actions.innerHTML = `
<div class="join-card-actions">
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/" class="btn btn-primary btn-firefox">
<img src="assets/firefox.svg" alt="Firefox" width="20" style="display: block;">
<span lang="en">GET IT ON MOZILLA ADD-ONS</span><span lang="de">IM FIREFOX ADD-ON STORE HERUNTERLADEN</span>
</a>
<a href="https://github.com/shik3i/KoalaSync" target="_blank" class="btn btn-secondary">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<span lang="en">Download via GitHub</span><span lang="de">Über GitHub herunterladen</span>
</a>
</div>
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">
<span lang="en">The extension is required to join and sync videos.</span>
<span lang="de">Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.</span>
</p>
`;
} else {
actions.innerHTML = `
<div class="join-card-actions">
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc" class="btn btn-primary">
<img src="assets/chrome.svg" alt="Chrome" width="20" style="display: block;">
<span lang="en">GET IT ON CHROME WEBSTORE</span><span lang="de">IM CHROME WEB STORE HERUNTERLADEN</span>
</a>
<a href="https://github.com/shik3i/KoalaSync" target="_blank" class="btn btn-secondary">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<span lang="en">Download via GitHub</span><span lang="de">Über GitHub herunterladen</span>
</a>
</div>
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">
<span lang="en">The extension is required to join and sync videos.</span>
<span lang="de">Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.</span>
</p>
`;
}
} else {
actions.innerHTML = `
<div class="joining-spinner" style="text-align:center; padding: 1rem;">
<div class="join-spinner"></div>
<div style="font-weight: 600; color: var(--accent);">
<span lang="en">Joining room automatically...</span><span lang="de">Raum wird automatisch betreten...</span>
</div>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
<span lang="en">Your extension is taking care of it.</span><span lang="de">Deine Erweiterung kümmert sich darum.</span>
</p>
</div>
`;
// AUTO-TRIGGER JOIN
setTimeout(() => {
window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', {
detail: {
roomId,
password,
useCustomServer: serverFlag === '1',
serverUrl: serverUrl
}
}));
}, 500);
}
}
} else {
// Fallback banner for index.html
if (!document.getElementById('koala-banner')) {
const banner = document.createElement('div');
banner.className = 'invite-banner';
banner.id = 'koala-banner';
const container = document.createElement('div');
container.className = 'container';
container.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
const inviteSpan = document.createElement('span');
inviteSpan.appendChild(document.createTextNode('🎫 Invitation for '));
const boldRoom = document.createElement('b');
boldRoom.textContent = roomId;
inviteSpan.appendChild(boldRoom);
inviteSpan.appendChild(document.createTextNode(' detected!'));
const joinLink = document.createElement('a');
joinLink.href = 'join' + window.location.hash;
joinLink.className = 'btn-banner';
joinLink.textContent = 'OPEN JOIN PAGE';
container.appendChild(inviteSpan);
container.appendChild(joinLink);
banner.appendChild(container);
document.body.prepend(banner);
}
}
// Global listener for Join Button
document.addEventListener('click', (e) => {
if (e.target && e.target.id === 'webJoinBtn') {
e.target.textContent = 'JOINING...';
e.target.disabled = true;
window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', {
detail: {
roomId,
password,
useCustomServer: serverFlag === '1',
serverUrl: serverUrl
}
}));
}
});
}
}
}, 600); // 600ms delay to ensure bridge.js has set the dataset
};
// Listen for status from Extension
window.addEventListener('KOALASYNC_STATUS', (e) => {
const { success, message } = e.detail;
const isJoinPage = window.location.pathname.includes('join');
if (isJoinPage) {
const icon = document.getElementById('join-status-icon');
const title = document.getElementById('join-title');
const actions = document.getElementById('join-actions');
const desc = document.getElementById('join-desc');
const ring = document.getElementById('status-ring');
if (success) {
if (ring) {
ring.classList.remove('active-pulse');
ring.style.display = 'none';
}
if (icon) {
icon.innerHTML = '<img src="assets/KoalaThumbsUp.webp" alt="Success" class="join-status-mascot">';
icon.style.transform = 'scale(1)';
}
const isDE = document.documentElement.classList.contains('lang-de');
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
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';
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';
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);
}
}
}
});
const updateDynamicVersion = async () => {
try {
const versionPath = '/version.json';
const response = await fetch(versionPath);
if (!response.ok) return;
const data = await response.json();
const { version, date } = data;
if (!version || !date) return;
const releaseDate = new Date(date);
const now = new Date();
const diffMs = now - releaseDate;
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';
}
const badgeEn = document.querySelector('.version-text-en');
const badgeDe = document.querySelector('.version-text-de');
if (badgeEn) {
badgeEn.textContent = `v${version} OUT NOW • ${relativeTimeEn}`;
}
if (badgeDe) {
badgeDe.textContent = `v${version} JETZT VERFÜGBAR • ${relativeTimeDe}`;
}
// Update Schema.org structured data dynamically
const schemaScript = document.getElementById('schema-software');
if (schemaScript) {
try {
const schema = JSON.parse(schemaScript.textContent);
schema.softwareVersion = version;
schemaScript.textContent = JSON.stringify(schema, null, 2);
} catch (err) {
console.warn('Failed to dynamically update schema version:', err);
}
}
} catch (e) {
console.warn('Failed to fetch dynamic version info:', e);
}
};
// 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');
}
});
});
// --- Hero Live Demo (two synced video tabs + extension popup) ---
// Desktop only: on mobile the scene falls back to the classic static popup.
const initHeroDemo = () => {
const scene = document.getElementById('hero-demo');
if (!scene || !window.matchMedia('(min-width: 769px)').matches) 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;
// Bouncing ball logic (bounce Y is computed as parabolic arc)
const bouncePeriod = 0.8;
const bounceProgress = (t % bouncePeriod) / bouncePeriod;
const bounceHeight = 16;
const bounceY = 4 * bounceProgress * (1 - bounceProgress) * -bounceHeight;
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');
};
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 pulse = (key) => {
const el = tabs[key].root;
el.classList.remove('sync-pulse');
void el.offsetWidth; // restart the CSS animation
el.classList.add('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;
root.classList.remove('demo-seeking');
// 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');
void film.offsetWidth; // force reflow so the browser commits the reset
film.classList.remove('demo-reset');
}
void root.offsetWidth;
root.classList.add('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);
// --- 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 sr = scene.getBoundingClientRect();
const from = inviteCopyBtn.getBoundingClientRect();
const to = tabs.b.root.querySelector('.demo-tab-titlebar').getBoundingClientRect();
inviteFly.style.transition = 'none';
inviteFly.style.left = (from.left - sr.left) + 'px';
inviteFly.style.top = (from.top - sr.top) + 'px';
inviteFly.style.opacity = '1';
void inviteFly.offsetWidth;
inviteFly.style.transition = '';
inviteFly.style.left = (to.left - sr.left + to.width / 2 - 40) + 'px';
inviteFly.style.top = (to.top - sr.top + 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;
}
videoSelect.classList.remove('demo-attn');
void videoSelect.offsetWidth;
videoSelect.classList.add('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);
}
void scene.offsetWidth;
scene.classList.remove('demo-no-anim');
const showHint = () => {
if (hint) hint.classList.add('show');
};
// Automated one-time 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;
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);
const runDemo = async () => {
if (demoStarted || userTookOver || reduceMotion) return;
demoStarted = true;
const wait = (ms) => new Promise(r => setTimeout(r, ms));
const moveTo = async (el, fx) => {
const sr = scene.getBoundingClientRect();
const er = el.getBoundingClientRect();
cursor.style.left = (er.left - sr.left + er.width * (fx || 0.5)) + 'px';
cursor.style.top = (er.top - sr.top + er.height / 2) + 'px';
await wait(700);
};
const step = async (el, pause, action, fx) => {
if (userTookOver || !el) return false;
await moveTo(el, fx);
if (userTookOver) return false;
cursor.classList.remove('clicking');
void cursor.offsetWidth;
cursor.classList.add('clicking');
await wait(180);
if (userTookOver) return false;
if (action) action(); else el.click();
await wait(320 + (pause || 0));
return !userTookOver;
};
await wait(500);
if (userTookOver) return;
cursor.classList.add('visible');
await wait(400);
const progressA = tabs.a.root.querySelector('.demo-progress');
// Act 1: open the extension and create a room
if (!await step(launcher, 300)) return;
if (!await step(createRoomBtn, 500)) return;
// Act 2: the invite link travels to the friend's browser
if (!await step(inviteCopyBtn, 0)) return;
await flyInvite();
if (userTookOver) return;
setConnected(true);
showToastB(toastB ? toastB.dataset.joined : '');
pulse('b');
await wait(900);
if (userTookOver) return;
// Act 3: both sides pick the video tab to sync
if (!await step(syncTabBtn, 250)) return;
if (!await step(videoSelect, 150, flashSelect)) return;
showToastB(toastB ? toastB.dataset.selected : '');
await wait(700);
if (userTookOver) return;
// Act 4: play for everyone, then tuck the popup away
if (!await step(playBtn, 1800)) return;
if (!await step(launcher, 500)) return;
// Act 5: any side can control — pause there, seek here, play again
if (!await step(tabs.b.root, 1000)) return;
if (!await step(progressA, 1300, () => seekTo('a', 0.62), 0.62)) return;
if (!await step(tabs.a.root, 700)) return;
finishDemo();
};
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();
// 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;
copyBtn.innerHTML = isDE ? '✅ Kopiert!' : '✅ Copied!';
copyBtn.disabled = true;
setTimeout(() => {
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 === 'alternatives/teleparty' || attr.endsWith('/alternatives')) {
link.href = (activeLang === 'en') ? 'alternatives' : `${activeLang}/alternatives`;
}
});
}
};
// 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.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(249, 115, 22, ' : 'rgba(99, 102, 241, ';
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);
}
});