mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
14c9ab1d5e
- 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
1306 lines
60 KiB
JavaScript
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);
|
|
}
|
|
});
|