mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-03 22:45:21 +00:00
work in progress: UI overhaul updates, new logo, and localization translations
This commit is contained in:
+387
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user