work in progress: UI overhaul updates, new logo, and localization translations

This commit is contained in:
KoalaDev
2026-07-03 10:01:15 +02:00
parent 44d452a82f
commit e5a4032710
21 changed files with 1442 additions and 17 deletions
+387
View File
@@ -406,6 +406,393 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// --- 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 = 8; // timelapse factor so the demo feels alive
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);
};
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 = () => {
['a', 'b'].forEach(k => {
const el = tabs[k].root;
el.classList.remove('sync-pulse');
void el.offsetWidth; // restart the CSS animation
el.classList.add('sync-pulse');
});
};
const NAMES = { a: 'CoolUsername', b: 'KoalaPC' };
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();
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]);
setTimeout(() => {
tabs[peerKey].t = tabs[sourceKey].t;
renderTab(tabs[peerKey]);
pulse();
broadcasting = false;
}, 90);
};
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 CoolUsername (tab A's user)
playBtn.addEventListener('click', () => {
if (tabs.a.playing && tabs.b.playing) { pulse(); return; }
broadcast('a', true);
});
pauseBtn.addEventListener('click', () => {
if (!tabs.a.playing && !tabs.b.playing) { pulse(); 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', '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;
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;
let demoFinishedByStory = false;
const finishDemo = () => {
if (demoFinished) return;
demoFinished = true;
setRoomJoined(true);
setConnected(true);
cursor.classList.remove('visible');
showHint();
};
const takeOver = (e) => {
const firstTake = !userTookOver;
userTookOver = true;
finishDemo();
// if the story left the popup open and the user clicked elsewhere,
// tuck it away; clicks inside the popup (or on its icon) keep it
if (firstTake && !demoFinishedByStory && 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();
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;
demoFinishedByStory = true;
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');