Spaces:
Running on Zero
Running on Zero
Deploy Karate Wiener (kimodo kata maker)
Browse files- tabs/whoami.drawer.html +6 -2
- tabs/whoami.tab.css +12 -2
- tabs/whoami.tab.js +132 -118
tabs/whoami.drawer.html
CHANGED
|
@@ -1,7 +1,11 @@
|
|
| 1 |
<aside id="kimodo-whoami-drawer" class="kimodo-drawer" aria-label="Who is Karate Wiener">
|
| 2 |
|
| 3 |
-
<!-- Hero portrait
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
<p class="kimodo-whoami-lead">Welcome, my student.</p>
|
| 7 |
|
|
|
|
| 1 |
<aside id="kimodo-whoami-drawer" class="kimodo-drawer" aria-label="Who is Karate Wiener">
|
| 2 |
|
| 3 |
+
<!-- Hero portrait + a play/stop button (right of the picture) for the welcome voice.
|
| 4 |
+
src + wiring set by whoami.tab.js from the dataset. -->
|
| 5 |
+
<div class="kimodo-whoami-herorow">
|
| 6 |
+
<img id="kimodo-whoami-hero-img" class="kimodo-whoami-hero-img" alt="Karate Wiener" />
|
| 7 |
+
<button id="kimodo-whoami-wspeak" class="kw-speak kimodo-whoami-wspeak" type="button" aria-label="Play welcome"></button>
|
| 8 |
+
</div>
|
| 9 |
|
| 10 |
<p class="kimodo-whoami-lead">Welcome, my student.</p>
|
| 11 |
|
tabs/whoami.tab.css
CHANGED
|
@@ -45,13 +45,15 @@
|
|
| 45 |
}
|
| 46 |
|
| 47 |
/* Who Am I panel content */
|
| 48 |
-
/* Hero portrait of Karate Wiener. */
|
|
|
|
| 49 |
.kimodo-whoami-hero-img {
|
| 50 |
-
display: block; width:
|
| 51 |
border-radius: 14px; border: 1px solid #5a3a7a;
|
| 52 |
background: linear-gradient(135deg, #2a1840 0%, #3a1d52 50%, #122a4a 100%);
|
| 53 |
box-shadow: 0 6px 18px rgba(0,0,0,0.45);
|
| 54 |
}
|
|
|
|
| 55 |
.kimodo-whoami-lead { font-size: 15px; font-weight: 600; color: #ffe6fb; margin: 8px 0; line-height: 1.45; }
|
| 56 |
/* Swipable testimonial strip: horizontal scroll-snap, tap a panel to open the lightbox. */
|
| 57 |
.kimodo-whoami-gallery {
|
|
@@ -80,6 +82,14 @@
|
|
| 80 |
background: rgba(40,24,64,0.9); color: #ffe6fb; border: 1px solid #c065e0; font-size: 20px;
|
| 81 |
line-height: 1; cursor: pointer; display: grid; place-items: center;
|
| 82 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
/* ---- Chat trigger button (end of the panel) ---- */
|
| 85 |
.kimodo-whoami-chatbtn {
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
/* Who Am I panel content */
|
| 48 |
+
/* Hero portrait of Karate Wiener + the welcome play/stop button to its right. */
|
| 49 |
+
.kimodo-whoami-herorow { display: flex; align-items: center; justify-content: center; gap: 12px; margin: 4px 0 12px; }
|
| 50 |
.kimodo-whoami-hero-img {
|
| 51 |
+
display: block; width: 56%; max-width: 200px; margin: 0;
|
| 52 |
border-radius: 14px; border: 1px solid #5a3a7a;
|
| 53 |
background: linear-gradient(135deg, #2a1840 0%, #3a1d52 50%, #122a4a 100%);
|
| 54 |
box-shadow: 0 6px 18px rgba(0,0,0,0.45);
|
| 55 |
}
|
| 56 |
+
.kimodo-whoami-wspeak { flex: 0 0 auto; align-self: center; width: 44px; height: 44px; font-size: 16px; }
|
| 57 |
.kimodo-whoami-lead { font-size: 15px; font-weight: 600; color: #ffe6fb; margin: 8px 0; line-height: 1.45; }
|
| 58 |
/* Swipable testimonial strip: horizontal scroll-snap, tap a panel to open the lightbox. */
|
| 59 |
.kimodo-whoami-gallery {
|
|
|
|
| 82 |
background: rgba(40,24,64,0.9); color: #ffe6fb; border: 1px solid #c065e0; font-size: 20px;
|
| 83 |
line-height: 1; cursor: pointer; display: grid; place-items: center;
|
| 84 |
}
|
| 85 |
+
/* Prev / next arrows (also swipe on touch). */
|
| 86 |
+
.kimodo-whoami-lightbox .kimodo-whoami-lbnav {
|
| 87 |
+
position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 44px; border-radius: 50%;
|
| 88 |
+
background: rgba(40,24,64,0.85); color: #ffe6fb; border: 1px solid #7c5cdc; font-size: 26px; line-height: 1;
|
| 89 |
+
cursor: pointer; display: grid; place-items: center;
|
| 90 |
+
}
|
| 91 |
+
.kimodo-whoami-lightbox .kw-prev { left: 12px; }
|
| 92 |
+
.kimodo-whoami-lightbox .kw-next { right: 12px; }
|
| 93 |
|
| 94 |
/* ---- Chat trigger button (end of the panel) ---- */
|
| 95 |
.kimodo-whoami-chatbtn {
|
tabs/whoami.tab.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
-
// WHO AM I tab — info on Karate Wiener,
|
| 2 |
-
//
|
| 3 |
-
// re-armed from the account drawer's "Restart tutorial" (kimodo-tutorial-restart event).
|
| 4 |
(function () {
|
| 5 |
var TKEY = 'kimodoTutorial';
|
| 6 |
function load() { try { return JSON.parse(localStorage.getItem(TKEY) || '{}') || {}; } catch (e) { return {}; } }
|
|
@@ -8,35 +7,34 @@
|
|
| 8 |
function tab() { return document.getElementById('kimodo-whoami-toggle'); }
|
| 9 |
function setPulse(on) { var t = tab(); if (t) t.classList.toggle('kimodo-whoami-pulse', !!on); }
|
| 10 |
function seen() { return !!load().whoamiSeen; }
|
| 11 |
-
|
| 12 |
-
// Pulse the tab until the user has opened it once (step 1 of the tutorial).
|
| 13 |
function applyState() { setPulse(!seen()); }
|
|
|
|
| 14 |
|
| 15 |
-
function markSeen() {
|
| 16 |
-
var o = load();
|
| 17 |
-
if (!o.whoamiSeen) { o.whoamiSeen = true; save(o); }
|
| 18 |
-
setPulse(false);
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
// ---- Hero portrait + swipable testimonial gallery (hosted in the dataset) ----
|
| 22 |
function whoamiBase() {
|
| 23 |
var b = (typeof DATASET_BASE !== 'undefined' && DATASET_BASE) ? DATASET_BASE : '';
|
| 24 |
return b + '/viewer/whoami/';
|
| 25 |
}
|
| 26 |
var TESTIMONIALS = 4;
|
| 27 |
var built = false;
|
|
|
|
|
|
|
|
|
|
| 28 |
function buildWhoami() {
|
| 29 |
if (built) return;
|
| 30 |
var base = whoamiBase();
|
| 31 |
var hero = document.getElementById('kimodo-whoami-hero-img');
|
| 32 |
if (hero && !hero.getAttribute('src')) hero.src = base + 'weiner.webp';
|
|
|
|
|
|
|
| 33 |
var gal = document.getElementById('kimodo-whoami-gallery');
|
| 34 |
if (gal && !gal.childNodes.length) {
|
|
|
|
| 35 |
for (var i = 1; i <= TESTIMONIALS; i++) {
|
| 36 |
var src = base + 'testimonial-' + i + '.webp';
|
|
|
|
| 37 |
var img = document.createElement('img');
|
| 38 |
img.src = src; img.alt = 'Student testimonial ' + i; img.loading = 'lazy';
|
| 39 |
-
img.addEventListener('click', (function (
|
| 40 |
gal.appendChild(img);
|
| 41 |
}
|
| 42 |
}
|
|
@@ -47,32 +45,82 @@
|
|
| 47 |
if (av && !av.getAttribute('src')) av.src = base + 'wiener-meditate.webp';
|
| 48 |
chatBtn.addEventListener('click', openChat);
|
| 49 |
}
|
| 50 |
-
ensureWelcome();
|
| 51 |
built = true;
|
| 52 |
}
|
| 53 |
-
|
| 54 |
-
//
|
| 55 |
-
//
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
document.body.appendChild(
|
| 65 |
-
|
| 66 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
-
function
|
| 69 |
-
var a =
|
| 70 |
-
try { a.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
// ---- Chat with Karate Wiener (phone-messaging modal) ----------------------
|
| 74 |
-
// Text reply via the #wiener-chat-* bridge; voice is on-demand per message via the 🔊
|
| 75 |
-
// speak button (#wiener-speak-*). Both are hidden gradio triggers (mirror voteKata).
|
| 76 |
var chatHistory = [];
|
| 77 |
var modal = null, msgsEl = null, textEl = null, sendBtn = null, chatBusy = false;
|
| 78 |
function wienerTrigger(prefix, payloadObj, timeoutMs, cb) {
|
|
@@ -111,6 +159,8 @@
|
|
| 111 |
sendBtn = modal.querySelector('#kw-chat-send');
|
| 112 |
modal.querySelector('.kw-chat-close').addEventListener('click', closeChat);
|
| 113 |
modal.addEventListener('click', function (e) { if (e.target === modal) closeChat(); });
|
|
|
|
|
|
|
| 114 |
sendBtn.addEventListener('click', sendMessage);
|
| 115 |
textEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
|
| 116 |
textEl.addEventListener('input', autoGrow);
|
|
@@ -119,11 +169,11 @@
|
|
| 119 |
function openChat() {
|
| 120 |
buildModal();
|
| 121 |
modal.classList.add('open');
|
| 122 |
-
// Opening the chat takes over from the panel welcome voice (don't talk over each other).
|
| 123 |
-
if (welcomeAudio) { try { welcomeAudio.pause(); } catch (e) {} }
|
| 124 |
// First message uses the fixed chat-intro recording (no TTS — it's always the same, so
|
| 125 |
-
// we save generation cost)
|
|
|
|
| 126 |
if (!chatHistory.length) addWiener('Welcome, my student. Ask me anything — karate, the spirit, or the law. Osu!', true, whoamiBase() + 'chat-intro.wav');
|
|
|
|
| 127 |
setTimeout(function () { if (textEl) textEl.focus(); }, 80);
|
| 128 |
}
|
| 129 |
function closeChat() { if (modal) modal.classList.remove('open'); }
|
|
@@ -133,69 +183,17 @@
|
|
| 133 |
row.appendChild(b); msgsEl.appendChild(row); scrollBottom();
|
| 134 |
chatHistory.push({ role: 'user', content: text });
|
| 135 |
}
|
| 136 |
-
// Wiener message: avatar,
|
| 137 |
-
// message. `autospeak` (true) loads + plays the voice at once. `fixedUrl` voices the
|
| 138 |
-
// message from a static file (the greeting) instead of generating TTS.
|
| 139 |
function addWiener(text, autospeak, fixedUrl) {
|
| 140 |
var row = document.createElement('div'); row.className = 'kw-msg kw-wiener';
|
| 141 |
var av = document.createElement('img'); av.className = 'kw-msgav'; av.src = whoamiBase() + 'wiener-meditate.webp'; av.alt = '';
|
| 142 |
var spk = document.createElement('button'); spk.type = 'button'; spk.className = 'kw-speak';
|
| 143 |
var b = document.createElement('div'); b.className = 'kw-bubble'; b.textContent = text;
|
| 144 |
-
row.appendChild(av); row.appendChild(b); row.appendChild(spk);
|
| 145 |
msgsEl.appendChild(row); scrollBottom();
|
| 146 |
chatHistory.push({ role: 'wiener', content: text });
|
| 147 |
wireSpeak(spk, text, autospeak !== false, fixedUrl);
|
| 148 |
}
|
| 149 |
-
// wiener-speak shares ONE hidden gradio trigger/result, so concurrent requests would
|
| 150 |
-
// cross (a message could play another message's audio). Serialize them: one TTS in flight
|
| 151 |
-
// at a time, each tagged to its own button via the callback — never the shared textbox race.
|
| 152 |
-
var speakQueue = [], speakActive = false;
|
| 153 |
-
function enqueueSpeak(text, cb) { speakQueue.push({ text: text, cb: cb }); pumpSpeak(); }
|
| 154 |
-
function pumpSpeak() {
|
| 155 |
-
if (speakActive || !speakQueue.length) return;
|
| 156 |
-
speakActive = true;
|
| 157 |
-
var job = speakQueue.shift();
|
| 158 |
-
wienerTrigger('wiener-speak', { text: job.text }, 120000, function (r) {
|
| 159 |
-
speakActive = false;
|
| 160 |
-
try { job.cb(r); } finally { pumpSpeak(); }
|
| 161 |
-
});
|
| 162 |
-
}
|
| 163 |
-
// Icon speak button: ▶ play / ■ stop, with a spinner while the voice loads/buffers. The
|
| 164 |
-
// voice (fixed file or generated TTS) plays the moment it's ready; tap ■ to stop, ▶ to
|
| 165 |
-
// replay the cached audio. Each button owns its own `audio`, so it always plays ITS text.
|
| 166 |
-
function wireSpeak(btn, text, autostart, fixedUrl) {
|
| 167 |
-
var audio = null;
|
| 168 |
-
function set(state) {
|
| 169 |
-
btn.dataset.state = state;
|
| 170 |
-
btn.innerHTML = state === 'loading' ? '<span class="kw-ld" aria-label="loading"></span>' : (state === 'playing' ? '■' : '▶');
|
| 171 |
-
btn.title = state === 'loading' ? 'loading voice…' : (state === 'playing' ? 'stop' : 'play voice');
|
| 172 |
-
btn.classList.toggle('loading', state === 'loading');
|
| 173 |
-
btn.classList.toggle('playing', state === 'playing');
|
| 174 |
-
}
|
| 175 |
-
function startPlay(src) {
|
| 176 |
-
audio = new Audio(src);
|
| 177 |
-
audio.addEventListener('ended', function () { set('idle'); });
|
| 178 |
-
var pr = audio.play();
|
| 179 |
-
if (pr && pr.then) pr.then(function () { set('playing'); }).catch(function () { set('idle'); });
|
| 180 |
-
else set('playing');
|
| 181 |
-
}
|
| 182 |
-
function load() {
|
| 183 |
-
set('loading'); // spinner while buffering / generating
|
| 184 |
-
if (fixedUrl) { startPlay(fixedUrl); return; } // static greeting voice — no TTS
|
| 185 |
-
enqueueSpeak(text, function (r) {
|
| 186 |
-
if (r && r.ok && r.audio) startPlay(r.audio);
|
| 187 |
-
else set('idle'); // failed → ▶ (tap to retry)
|
| 188 |
-
});
|
| 189 |
-
}
|
| 190 |
-
btn.addEventListener('click', function () {
|
| 191 |
-
var s = btn.dataset.state;
|
| 192 |
-
if (s === 'loading') return;
|
| 193 |
-
if (s === 'playing') { try { audio.pause(); audio.currentTime = 0; } catch (e) {} set('idle'); return; }
|
| 194 |
-
if (audio) { try { audio.currentTime = 0; var pr = audio.play(); if (pr && pr.catch) pr.catch(function () {}); set('playing'); } catch (e) {} }
|
| 195 |
-
else load(); // first manual play, or retry after a failed auto-load
|
| 196 |
-
});
|
| 197 |
-
if (autostart) load(); else set('idle');
|
| 198 |
-
}
|
| 199 |
function sendMessage() {
|
| 200 |
if (chatBusy || !textEl) return;
|
| 201 |
var text = (textEl.value || '').trim();
|
|
@@ -209,64 +207,80 @@
|
|
| 209 |
wienerTrigger('wiener-chat', { history: hist, message: text }, 120000, function (r) {
|
| 210 |
if (typing.parentNode) typing.parentNode.removeChild(typing);
|
| 211 |
chatBusy = false; if (sendBtn) sendBtn.disabled = false;
|
| 212 |
-
if (r && r.ok && r.reply) addWiener(r.reply, true);
|
| 213 |
else addWiener('The path is quiet right now, my student. Ask me again. Osu!', true);
|
| 214 |
});
|
| 215 |
}
|
| 216 |
|
| 217 |
-
//
|
| 218 |
-
var lb = null;
|
| 219 |
function ensureLightbox() {
|
| 220 |
if (lb) return lb;
|
| 221 |
lb = document.createElement('div'); lb.className = 'kimodo-whoami-lightbox';
|
| 222 |
-
|
| 223 |
var close = document.createElement('button'); close.type = 'button'; close.className = 'kimodo-whoami-lbclose'; close.setAttribute('aria-label', 'Close'); close.textContent = '✕';
|
| 224 |
-
|
| 225 |
-
|
|
|
|
| 226 |
document.body.appendChild(lb);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
return lb;
|
| 228 |
}
|
| 229 |
-
function
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
}
|
|
|
|
| 234 |
function closeLightbox() { if (lb) lb.classList.remove('open'); }
|
| 235 |
-
window.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeLightbox(); if (typeof closeChat === 'function') closeChat(); } });
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
window.addEventListener('kimodo-drawer-open', function (e) {
|
| 240 |
-
if (e.detail === 'kimodo-whoami-drawer') { markSeen(); buildWhoami();
|
| 241 |
});
|
| 242 |
|
| 243 |
// Restart the tutorial (from the account drawer): re-arm step 1's pulse.
|
| 244 |
window.addEventListener('kimodo-tutorial-restart', function () {
|
| 245 |
-
var o = load(); o.whoamiSeen = false; save(o);
|
| 246 |
-
setPulse(true);
|
| 247 |
});
|
| 248 |
|
| 249 |
-
// Don't start the first-run pulse until the intro TITLE overlay has finished.
|
| 250 |
-
// (#kw-title-overlay) is added just after this script and REMOVED from the DOM when it
|
| 251 |
-
// ends; it covers the whole screen meanwhile. So: wait until it has appeared and gone,
|
| 252 |
-
// or — if no intro exists (no audio asset) — proceed after a short grace window.
|
| 253 |
function gateInitialPulse() {
|
| 254 |
var present = function () { return !!document.getElementById('kw-title-overlay'); };
|
| 255 |
var sawIntro = present();
|
| 256 |
var iv = setInterval(function () {
|
| 257 |
-
if (present()) { sawIntro = true; return; }
|
| 258 |
-
if (sawIntro) { clearInterval(iv); applyState(); }
|
| 259 |
}, 250);
|
| 260 |
-
// No intro showed up within the grace window => nothing to wait for.
|
| 261 |
setTimeout(function () { if (!sawIntro && !present()) { clearInterval(iv); applyState(); } }, 1300);
|
| 262 |
-
// Hard safety: never wait forever.
|
| 263 |
setTimeout(function () { clearInterval(iv); applyState(); }, 15000);
|
| 264 |
}
|
| 265 |
gateInitialPulse();
|
| 266 |
-
//
|
| 267 |
-
//
|
| 268 |
setTimeout(function () {
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
| 271 |
}, 1500);
|
| 272 |
})();
|
|
|
|
| 1 |
+
// WHO AM I tab — info on Karate Wiener, voiced welcome + testimonial gallery + a chat
|
| 2 |
+
// modal. Tutorial STEP 1: the rail tab pulses on first start until opened.
|
|
|
|
| 3 |
(function () {
|
| 4 |
var TKEY = 'kimodoTutorial';
|
| 5 |
function load() { try { return JSON.parse(localStorage.getItem(TKEY) || '{}') || {}; } catch (e) { return {}; } }
|
|
|
|
| 7 |
function tab() { return document.getElementById('kimodo-whoami-toggle'); }
|
| 8 |
function setPulse(on) { var t = tab(); if (t) t.classList.toggle('kimodo-whoami-pulse', !!on); }
|
| 9 |
function seen() { return !!load().whoamiSeen; }
|
|
|
|
|
|
|
| 10 |
function applyState() { setPulse(!seen()); }
|
| 11 |
+
function markSeen() { var o = load(); if (!o.whoamiSeen) { o.whoamiSeen = true; save(o); } setPulse(false); }
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
function whoamiBase() {
|
| 14 |
var b = (typeof DATASET_BASE !== 'undefined' && DATASET_BASE) ? DATASET_BASE : '';
|
| 15 |
return b + '/viewer/whoami/';
|
| 16 |
}
|
| 17 |
var TESTIMONIALS = 4;
|
| 18 |
var built = false;
|
| 19 |
+
var galleryImages = []; // testimonial srcs (lightbox swipes through these)
|
| 20 |
+
var welcomeBtn = null; // the welcome speak/stop button (right of the hero image)
|
| 21 |
+
|
| 22 |
function buildWhoami() {
|
| 23 |
if (built) return;
|
| 24 |
var base = whoamiBase();
|
| 25 |
var hero = document.getElementById('kimodo-whoami-hero-img');
|
| 26 |
if (hero && !hero.getAttribute('src')) hero.src = base + 'weiner.webp';
|
| 27 |
+
welcomeBtn = document.getElementById('kimodo-whoami-wspeak');
|
| 28 |
+
if (welcomeBtn && !welcomeBtn._wired) { welcomeBtn._wired = true; wireSpeak(welcomeBtn, '', false, base + 'wiener-welcome.wav'); }
|
| 29 |
var gal = document.getElementById('kimodo-whoami-gallery');
|
| 30 |
if (gal && !gal.childNodes.length) {
|
| 31 |
+
galleryImages = [];
|
| 32 |
for (var i = 1; i <= TESTIMONIALS; i++) {
|
| 33 |
var src = base + 'testimonial-' + i + '.webp';
|
| 34 |
+
galleryImages.push(src);
|
| 35 |
var img = document.createElement('img');
|
| 36 |
img.src = src; img.alt = 'Student testimonial ' + i; img.loading = 'lazy';
|
| 37 |
+
img.addEventListener('click', (function (idx) { return function () { openLightbox(idx); }; })(i - 1));
|
| 38 |
gal.appendChild(img);
|
| 39 |
}
|
| 40 |
}
|
|
|
|
| 45 |
if (av && !av.getAttribute('src')) av.src = base + 'wiener-meditate.webp';
|
| 46 |
chatBtn.addEventListener('click', openChat);
|
| 47 |
}
|
|
|
|
| 48 |
built = true;
|
| 49 |
}
|
| 50 |
+
|
| 51 |
+
// ---- Shared voice player ---------------------------------------------------
|
| 52 |
+
// ONE <audio> element for every voice (welcome + chat). iOS only auto-plays audio that
|
| 53 |
+
// was first started inside a user gesture; reusing a single element that a gesture-play
|
| 54 |
+
// (the welcome on tab tap / the chat-intro on open) has "unlocked" lets later replies
|
| 55 |
+
// auto-play without their own gesture. Only one voice plays at a time.
|
| 56 |
+
var voiceEl = null, curBtn = null;
|
| 57 |
+
function voiceElement() {
|
| 58 |
+
if (voiceEl) return voiceEl;
|
| 59 |
+
voiceEl = document.createElement('audio');
|
| 60 |
+
voiceEl.setAttribute('playsinline', ''); voiceEl.style.display = 'none';
|
| 61 |
+
document.body.appendChild(voiceEl);
|
| 62 |
+
voiceEl.addEventListener('ended', function () { if (curBtn && curBtn._set) curBtn._set('idle'); curBtn = null; });
|
| 63 |
+
return voiceEl;
|
| 64 |
+
}
|
| 65 |
+
function playVoice(src, btn) {
|
| 66 |
+
var a = voiceElement();
|
| 67 |
+
if (curBtn && curBtn !== btn && curBtn._set) curBtn._set('idle'); // switch: reset the previous button
|
| 68 |
+
curBtn = btn;
|
| 69 |
+
try { a.src = src; a.currentTime = 0; } catch (e) {}
|
| 70 |
+
var pr = a.play();
|
| 71 |
+
if (pr && pr.then) pr.then(function () { if (btn._set) btn._set('playing'); }).catch(function () { if (btn._set) btn._set('idle'); });
|
| 72 |
+
else if (btn._set) btn._set('playing');
|
| 73 |
}
|
| 74 |
+
function stopVoice() {
|
| 75 |
+
var a = voiceElement();
|
| 76 |
+
try { a.pause(); a.currentTime = 0; } catch (e) {}
|
| 77 |
+
if (curBtn && curBtn._set) curBtn._set('idle');
|
| 78 |
+
curBtn = null;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// wiener-speak shares ONE hidden gradio trigger/result → serialize so concurrent TTS
|
| 82 |
+
// requests never cross (a message playing another's audio).
|
| 83 |
+
var speakQueue = [], speakActive = false;
|
| 84 |
+
function enqueueSpeak(text, cb) { speakQueue.push({ text: text, cb: cb }); pumpSpeak(); }
|
| 85 |
+
function pumpSpeak() {
|
| 86 |
+
if (speakActive || !speakQueue.length) return;
|
| 87 |
+
speakActive = true;
|
| 88 |
+
var job = speakQueue.shift();
|
| 89 |
+
wienerTrigger('wiener-speak', { text: job.text }, 120000, function (r) {
|
| 90 |
+
speakActive = false;
|
| 91 |
+
try { job.cb(r); } finally { pumpSpeak(); }
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
// Icon speak button: ▶ play / ■ stop, spinner while the voice loads. `fixedUrl` voices it
|
| 95 |
+
// from a static file (welcome / chat-intro — no TTS). Plays the moment it's ready; the
|
| 96 |
+
// cached src means replays are instant and always play THIS message's audio.
|
| 97 |
+
function wireSpeak(btn, text, autostart, fixedUrl) {
|
| 98 |
+
var src = fixedUrl || null;
|
| 99 |
+
btn._set = function (state) {
|
| 100 |
+
btn.dataset.state = state;
|
| 101 |
+
btn.innerHTML = state === 'loading' ? '<span class="kw-ld" aria-label="loading"></span>' : (state === 'playing' ? '■' : '▶');
|
| 102 |
+
btn.title = state === 'loading' ? 'loading voice…' : (state === 'playing' ? 'stop' : 'play voice');
|
| 103 |
+
btn.classList.toggle('loading', state === 'loading');
|
| 104 |
+
btn.classList.toggle('playing', state === 'playing');
|
| 105 |
+
};
|
| 106 |
+
btn._play = function () {
|
| 107 |
+
if (src) { playVoice(src, btn); return; }
|
| 108 |
+
btn._set('loading');
|
| 109 |
+
enqueueSpeak(text, function (r) {
|
| 110 |
+
if (r && r.ok && r.audio) { src = r.audio; playVoice(src, btn); }
|
| 111 |
+
else btn._set('idle'); // failed → ▶ (tap to retry)
|
| 112 |
+
});
|
| 113 |
+
};
|
| 114 |
+
btn.addEventListener('click', function () {
|
| 115 |
+
var s = btn.dataset.state;
|
| 116 |
+
if (s === 'loading') return;
|
| 117 |
+
if (s === 'playing') { stopVoice(); return; }
|
| 118 |
+
btn._play();
|
| 119 |
+
});
|
| 120 |
+
if (autostart) btn._play(); else btn._set('idle');
|
| 121 |
}
|
| 122 |
|
| 123 |
// ---- Chat with Karate Wiener (phone-messaging modal) ----------------------
|
|
|
|
|
|
|
| 124 |
var chatHistory = [];
|
| 125 |
var modal = null, msgsEl = null, textEl = null, sendBtn = null, chatBusy = false;
|
| 126 |
function wienerTrigger(prefix, payloadObj, timeoutMs, cb) {
|
|
|
|
| 159 |
sendBtn = modal.querySelector('#kw-chat-send');
|
| 160 |
modal.querySelector('.kw-chat-close').addEventListener('click', closeChat);
|
| 161 |
modal.addEventListener('click', function (e) { if (e.target === modal) closeChat(); });
|
| 162 |
+
// Don't let taps inside the modal reach the document handler that closes the drawer.
|
| 163 |
+
modal.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
|
| 164 |
sendBtn.addEventListener('click', sendMessage);
|
| 165 |
textEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
|
| 166 |
textEl.addEventListener('input', autoGrow);
|
|
|
|
| 169 |
function openChat() {
|
| 170 |
buildModal();
|
| 171 |
modal.classList.add('open');
|
|
|
|
|
|
|
| 172 |
// First message uses the fixed chat-intro recording (no TTS — it's always the same, so
|
| 173 |
+
// we save generation cost). Playing it here (inside the chat-button tap) unlocks the
|
| 174 |
+
// shared voice element, so later replies can auto-play without their own gesture.
|
| 175 |
if (!chatHistory.length) addWiener('Welcome, my student. Ask me anything — karate, the spirit, or the law. Osu!', true, whoamiBase() + 'chat-intro.wav');
|
| 176 |
+
else stopVoice();
|
| 177 |
setTimeout(function () { if (textEl) textEl.focus(); }, 80);
|
| 178 |
}
|
| 179 |
function closeChat() { if (modal) modal.classList.remove('open'); }
|
|
|
|
| 183 |
row.appendChild(b); msgsEl.appendChild(row); scrollBottom();
|
| 184 |
chatHistory.push({ role: 'user', content: text });
|
| 185 |
}
|
| 186 |
+
// Wiener message: avatar, bubble, then the play/stop ICON to the right of the message.
|
|
|
|
|
|
|
| 187 |
function addWiener(text, autospeak, fixedUrl) {
|
| 188 |
var row = document.createElement('div'); row.className = 'kw-msg kw-wiener';
|
| 189 |
var av = document.createElement('img'); av.className = 'kw-msgav'; av.src = whoamiBase() + 'wiener-meditate.webp'; av.alt = '';
|
| 190 |
var spk = document.createElement('button'); spk.type = 'button'; spk.className = 'kw-speak';
|
| 191 |
var b = document.createElement('div'); b.className = 'kw-bubble'; b.textContent = text;
|
| 192 |
+
row.appendChild(av); row.appendChild(b); row.appendChild(spk);
|
| 193 |
msgsEl.appendChild(row); scrollBottom();
|
| 194 |
chatHistory.push({ role: 'wiener', content: text });
|
| 195 |
wireSpeak(spk, text, autospeak !== false, fixedUrl);
|
| 196 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
function sendMessage() {
|
| 198 |
if (chatBusy || !textEl) return;
|
| 199 |
var text = (textEl.value || '').trim();
|
|
|
|
| 207 |
wienerTrigger('wiener-chat', { history: hist, message: text }, 120000, function (r) {
|
| 208 |
if (typing.parentNode) typing.parentNode.removeChild(typing);
|
| 209 |
chatBusy = false; if (sendBtn) sendBtn.disabled = false;
|
| 210 |
+
if (r && r.ok && r.reply) addWiener(r.reply, true);
|
| 211 |
else addWiener('The path is quiet right now, my student. Ask me again. Osu!', true);
|
| 212 |
});
|
| 213 |
}
|
| 214 |
|
| 215 |
+
// ---- Testimonial lightbox (swipable) --------------------------------------
|
| 216 |
+
var lb = null, lbImg = null, lbIdx = 0;
|
| 217 |
function ensureLightbox() {
|
| 218 |
if (lb) return lb;
|
| 219 |
lb = document.createElement('div'); lb.className = 'kimodo-whoami-lightbox';
|
| 220 |
+
lbImg = document.createElement('img'); lbImg.className = 'kimodo-whoami-lbimg'; lbImg.alt = 'Testimonial';
|
| 221 |
var close = document.createElement('button'); close.type = 'button'; close.className = 'kimodo-whoami-lbclose'; close.setAttribute('aria-label', 'Close'); close.textContent = '✕';
|
| 222 |
+
var prev = document.createElement('button'); prev.type = 'button'; prev.className = 'kimodo-whoami-lbnav kw-prev'; prev.setAttribute('aria-label', 'Previous'); prev.textContent = '‹';
|
| 223 |
+
var next = document.createElement('button'); next.type = 'button'; next.className = 'kimodo-whoami-lbnav kw-next'; next.setAttribute('aria-label', 'Next'); next.textContent = '›';
|
| 224 |
+
lb.appendChild(lbImg); lb.appendChild(close); lb.appendChild(prev); lb.appendChild(next);
|
| 225 |
document.body.appendChild(lb);
|
| 226 |
+
// Keep clicks/taps inside the lightbox from reaching the document handler that dismisses
|
| 227 |
+
// the WHO AM I drawer — closing the lightbox must leave the tab open.
|
| 228 |
+
lb.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
|
| 229 |
+
lb.addEventListener('click', function (e) { if (e.target === lb) closeLightbox(); }); // backdrop only
|
| 230 |
+
close.addEventListener('click', function (e) { e.stopPropagation(); closeLightbox(); });
|
| 231 |
+
prev.addEventListener('click', function (e) { e.stopPropagation(); showLb(lbIdx - 1); });
|
| 232 |
+
next.addEventListener('click', function (e) { e.stopPropagation(); showLb(lbIdx + 1); });
|
| 233 |
+
// Swipe left/right to move between testimonials.
|
| 234 |
+
var sx = 0, sy = 0;
|
| 235 |
+
lb.addEventListener('touchstart', function (e) { var t = e.changedTouches[0]; sx = t.clientX; sy = t.clientY; }, { passive: true });
|
| 236 |
+
lb.addEventListener('touchend', function (e) { var t = e.changedTouches[0]; var dx = t.clientX - sx, dy = t.clientY - sy; if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) showLb(lbIdx + (dx < 0 ? 1 : -1)); }, { passive: true });
|
| 237 |
return lb;
|
| 238 |
}
|
| 239 |
+
function showLb(idx) {
|
| 240 |
+
if (!galleryImages.length) return;
|
| 241 |
+
lbIdx = (idx + galleryImages.length) % galleryImages.length; // wrap around
|
| 242 |
+
ensureLightbox(); lbImg.src = galleryImages[lbIdx];
|
| 243 |
}
|
| 244 |
+
function openLightbox(idx) { ensureLightbox(); showLb(idx || 0); lb.classList.add('open'); }
|
| 245 |
function closeLightbox() { if (lb) lb.classList.remove('open'); }
|
|
|
|
| 246 |
|
| 247 |
+
window.addEventListener('keydown', function (e) {
|
| 248 |
+
var lbOpen = lb && lb.classList.contains('open');
|
| 249 |
+
if (e.key === 'Escape') { closeLightbox(); closeChat(); }
|
| 250 |
+
else if (lbOpen && e.key === 'ArrowLeft') showLb(lbIdx - 1);
|
| 251 |
+
else if (lbOpen && e.key === 'ArrowRight') showLb(lbIdx + 1);
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
// Opening the WHO AM I drawer completes step 1, builds the panel, and plays the welcome
|
| 255 |
+
// voice (inside the tab-tap gesture, so it actually auto-plays + unlocks the voice element).
|
| 256 |
window.addEventListener('kimodo-drawer-open', function (e) {
|
| 257 |
+
if (e.detail === 'kimodo-whoami-drawer') { markSeen(); buildWhoami(); if (welcomeBtn) welcomeBtn._play(); }
|
| 258 |
});
|
| 259 |
|
| 260 |
// Restart the tutorial (from the account drawer): re-arm step 1's pulse.
|
| 261 |
window.addEventListener('kimodo-tutorial-restart', function () {
|
| 262 |
+
var o = load(); o.whoamiSeen = false; save(o); setPulse(true);
|
|
|
|
| 263 |
});
|
| 264 |
|
| 265 |
+
// Don't start the first-run pulse until the intro TITLE overlay has finished.
|
|
|
|
|
|
|
|
|
|
| 266 |
function gateInitialPulse() {
|
| 267 |
var present = function () { return !!document.getElementById('kw-title-overlay'); };
|
| 268 |
var sawIntro = present();
|
| 269 |
var iv = setInterval(function () {
|
| 270 |
+
if (present()) { sawIntro = true; return; }
|
| 271 |
+
if (sawIntro) { clearInterval(iv); applyState(); }
|
| 272 |
}, 250);
|
|
|
|
| 273 |
setTimeout(function () { if (!sawIntro && !present()) { clearInterval(iv); applyState(); } }, 1300);
|
|
|
|
| 274 |
setTimeout(function () { clearInterval(iv); applyState(); }, 15000);
|
| 275 |
}
|
| 276 |
gateInitialPulse();
|
| 277 |
+
// Warm the voice file caches once DATASET_BASE is ready so the first welcome / chat-intro
|
| 278 |
+
// plays instantly.
|
| 279 |
setTimeout(function () {
|
| 280 |
+
try {
|
| 281 |
+
['wiener-welcome.wav', 'chat-intro.wav'].forEach(function (f) {
|
| 282 |
+
var a = document.createElement('audio'); a.preload = 'auto'; a.src = whoamiBase() + f; a.style.display = 'none'; document.body.appendChild(a); a.load();
|
| 283 |
+
});
|
| 284 |
+
} catch (e) {}
|
| 285 |
}, 1500);
|
| 286 |
})();
|