Skip to content

Commit 712a3da

Browse files
caswelltomclaude
andcommitted
v1.0.16: TTS fix (AudioContext/iOS), typewriter animation, swipe handle, avatar glow, Quick Study chip, personalization
- Fix TTS on iOS Chrome: unlock shared AudioContext synchronously in user gesture (handleSpeak), then decode/play via decodeAudioData in Promise chain — avoids autoplay-policy rejection from HTMLAudioElement.play() - Add typewriter animation: SSE tokens queued; 20ms interval reveals 5 chars/tick with smooth scroll to keep new text visible - Avatar pulse/glow on TTS instead of bounce: CSS @Keyframes aica-speaking-glow applied to toggle button via .aica-speaking class - Restrict mobile swipe-to-close to dedicated .aica-swipe-handle bar above header — prevents accidental dismissal when scrolling content - Add Quick Study starter chip: time picker (5/10/15/30 min) + topic selector (AI-guided default + course topics + custom); tracks sessions in localStorage - Personalization: store last session topic; show 'Continue where you left off' suggestion on re-open; tracks quiz history for welcome-back context - db/upgrade.php: add savepoint for 2026030700 (no schema changes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 576cc20 commit 712a3da

File tree

11 files changed

+521
-34
lines changed

11 files changed

+521
-34
lines changed

amd/build/chat.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/build/chat.min.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/build/ui.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/build/ui.min.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/src/chat.js

Lines changed: 329 additions & 4 deletions
Large diffs are not rendered by default.

amd/src/ui.js

Lines changed: 138 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,14 @@ define([
409409

410410
/** @type {HTMLElement|null} Current streaming message element */
411411
let streamingEl = null;
412+
/** Typewriter: full text received from SSE (may be ahead of visual display) */
413+
let typewriterFull = '';
414+
/** Typewriter: number of characters currently displayed */
415+
let typewriterPos = 0;
416+
/** Typewriter: setInterval handle */
417+
let typewriterTimer = null;
418+
/** Characters revealed per 20ms tick (~200 chars/sec) */
419+
const TYPEWRITER_SPEED = 5;
412420

413421
/** @type {HTMLElement|null} Currently playing TTS message element */
414422
let speakingEl = null;
@@ -765,20 +773,23 @@ define([
765773
* Safe to call on desktop too — touch events simply never fire there.
766774
*/
767775
const initMobileGestures = function() {
768-
// ── Mobile: swipe-down on header to close ────────────────────────────────
776+
// ── Mobile: swipe-down on swipe handle to close ──────────────────────────
777+
// Restricted to the dedicated .aica-swipe-handle bar to avoid accidental
778+
// dismissal when scrolling through messages or swiping on content.
769779
var swipeTouchStartY = 0;
770780
var swipeTouchStartX = 0;
771-
if (drawer) {
772-
drawer.addEventListener('touchstart', function(e) {
781+
var swipeHandle = drawer ? drawer.querySelector('.aica-swipe-handle') : null;
782+
if (swipeHandle) {
783+
swipeHandle.addEventListener('touchstart', function(e) {
773784
swipeTouchStartY = e.touches[0].clientY;
774785
swipeTouchStartX = e.touches[0].clientX;
775786
}, {passive: true});
776787

777-
drawer.addEventListener('touchend', function(e) {
788+
swipeHandle.addEventListener('touchend', function(e) {
778789
var dy = e.changedTouches[0].clientY - swipeTouchStartY;
779790
var dx = Math.abs(e.changedTouches[0].clientX - swipeTouchStartX);
780-
// Swipe down ≥ 80px, more vertical than horizontal → close drawer.
781-
if (dy > 80 && dx < dy * 0.8) {
791+
// Swipe down ≥ 60px, more vertical than horizontal → close drawer.
792+
if (dy > 60 && dx < dy * 0.8) {
782793
closeDrawer();
783794
}
784795
}, {passive: true});
@@ -1152,6 +1163,10 @@ define([
11521163
av.classList.toggle('aica-avatar-svg--speaking', !!on);
11531164
});
11541165
}
1166+
// Pulse-glow the toggle button itself (drives the CSS @keyframes aica-speaking-glow).
1167+
if (toggle) {
1168+
toggle.classList.toggle('aica-speaking', !!on);
1169+
}
11551170
};
11561171

11571172
/**
@@ -1166,21 +1181,74 @@ define([
11661181
};
11671182

11681183
/**
1169-
* Append a chunk to the current streaming message.
1170-
* Re-renders all accumulated text through markdown on each chunk.
1184+
* Scroll just enough to keep the bottom of the streaming content visible.
1185+
* Unlike scrollToBottom, this doesn't snap to the very end of the messages container.
1186+
*/
1187+
const scrollToKeepStreamingVisible = function() {
1188+
if (!messagesContainer || !streamingEl) {
1189+
return;
1190+
}
1191+
const content = streamingEl.querySelector('.local-ai-course-assistant__message-content');
1192+
if (!content) {
1193+
return;
1194+
}
1195+
const cRect = messagesContainer.getBoundingClientRect();
1196+
const eRect = content.getBoundingClientRect();
1197+
if (eRect.bottom > cRect.bottom - 4) {
1198+
messagesContainer.scrollTop += (eRect.bottom - cRect.bottom) + 6;
1199+
}
1200+
};
1201+
1202+
/**
1203+
* Internal: advance the typewriter by TYPEWRITER_SPEED characters and re-render.
1204+
*/
1205+
const tickTypewriter = function() {
1206+
if (!streamingEl) {
1207+
clearTypewriterTimer();
1208+
return;
1209+
}
1210+
const target = typewriterFull.length;
1211+
if (typewriterPos >= target) {
1212+
return; // Caught up — more tokens may still arrive
1213+
}
1214+
typewriterPos = Math.min(typewriterPos + TYPEWRITER_SPEED, target);
1215+
const partial = typewriterFull.substring(0, typewriterPos);
1216+
const content = streamingEl.querySelector('.local-ai-course-assistant__message-content');
1217+
if (content) {
1218+
content.innerHTML = Markdown.render(partial);
1219+
if (scrollFollowMode) {
1220+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
1221+
} else {
1222+
scrollToKeepStreamingVisible();
1223+
}
1224+
}
1225+
};
1226+
1227+
/**
1228+
* Clear the typewriter timer and reset state.
1229+
*/
1230+
const clearTypewriterTimer = function() {
1231+
if (typewriterTimer) {
1232+
clearInterval(typewriterTimer);
1233+
typewriterTimer = null;
1234+
}
1235+
typewriterFull = '';
1236+
typewriterPos = 0;
1237+
};
1238+
1239+
/**
1240+
* Append a chunk to the current streaming message using typewriter animation.
1241+
* Queues the new text; a timer reveals it character-by-character.
11711242
*
11721243
* @param {string} fullText The full accumulated text so far
11731244
*/
11741245
const updateStreamContent = function(fullText) {
11751246
if (!streamingEl) {
11761247
return;
11771248
}
1178-
const content = streamingEl.querySelector('.local-ai-course-assistant__message-content');
1179-
content.innerHTML = Markdown.render(fullText);
1180-
// In follow mode (user clicked the scroll-down arrow during streaming),
1181-
// keep the bottom of new content visible. Otherwise stay at message top.
1182-
if (scrollFollowMode) {
1183-
messagesContainer.scrollTop = messagesContainer.scrollHeight;
1249+
typewriterFull = fullText;
1250+
if (!typewriterTimer) {
1251+
typewriterTimer = setInterval(tickTypewriter, 20);
11841252
}
11851253
};
11861254

@@ -1191,6 +1259,8 @@ define([
11911259
* @param {Function|null} onSpeak Optional TTS callback; if provided, adds speak button
11921260
*/
11931261
const finishStreaming = function(fullText, onSpeak) {
1262+
// Stop typewriter animation — final render below replaces it.
1263+
clearTypewriterTimer();
11941264
if (streamingEl) {
11951265
const completedEl = streamingEl;
11961266
const content = streamingEl.querySelector('.local-ai-course-assistant__message-content');
@@ -2590,6 +2660,59 @@ define([
25902660
}
25912661
};
25922662

2663+
/**
2664+
* Start mouth sync from an already-created AnalyserNode (AudioContext TTS path).
2665+
* Used when the audio is played through a shared AudioContext (e.g. iOS TTS fix).
2666+
*
2667+
* @param {AnalyserNode} analyser Pre-configured analyser connected to audio source
2668+
*/
2669+
const startMouthSyncFromAnalyser = function(analyser) {
2670+
stopMouthSync();
2671+
if (!root || !analyser) {
2672+
return;
2673+
}
2674+
analyser.fftSize = 256;
2675+
analyser.smoothingTimeConstant = 0.55;
2676+
var data = new Uint8Array(analyser.frequencyBinCount);
2677+
var rafId = null;
2678+
var alive = true;
2679+
2680+
var resetMouth = function() {
2681+
if (!root) { return; }
2682+
root.querySelectorAll('.aica-mouth-open').forEach(function(el) {
2683+
el.style.transform = 'scaleY(0)';
2684+
el.style.opacity = '0';
2685+
});
2686+
root.querySelectorAll('.aica-mouth-smile').forEach(function(el) {
2687+
el.style.opacity = '';
2688+
});
2689+
};
2690+
2691+
mouthSyncCleanup = function() {
2692+
alive = false;
2693+
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
2694+
mouthSyncCleanup = null;
2695+
resetMouth();
2696+
};
2697+
2698+
var update = function() {
2699+
if (!alive) { return; }
2700+
analyser.getByteFrequencyData(data);
2701+
var sum = 0;
2702+
for (var i = 2; i <= 20; i++) { sum += data[i]; }
2703+
var amp = Math.min(1, (sum / 19 / 90));
2704+
root.querySelectorAll('.aica-mouth-open').forEach(function(el) {
2705+
el.style.transform = 'scaleY(' + amp + ')';
2706+
el.style.opacity = amp > 0.08 ? '1' : '0';
2707+
});
2708+
root.querySelectorAll('.aica-mouth-smile').forEach(function(el) {
2709+
el.style.opacity = String(Math.max(0.05, 1 - amp * 1.3));
2710+
});
2711+
rafId = requestAnimationFrame(update);
2712+
};
2713+
update();
2714+
};
2715+
25932716
/**
25942717
* Stop Web Audio mouth sync and reset mouth to resting state.
25952718
*/
@@ -2665,6 +2788,7 @@ define([
26652788
highlightWordAt: highlightWordAt,
26662789
stopWordHighlight: stopWordHighlight,
26672790
startMouthSync: startMouthSync,
2791+
startMouthSyncFromAnalyser: startMouthSyncFromAnalyser,
26682792
stopMouthSync: stopMouthSync,
26692793
isStartersVisible: isStartersVisible,
26702794
};

db/upgrade.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,5 +189,11 @@ function xmldb_local_ai_course_assistant_upgrade($oldversion) {
189189
upgrade_plugin_savepoint(true, 2025022600, 'local', 'ai_course_assistant');
190190
}
191191

192+
if ($oldversion < 2026030700) {
193+
// v1.0.16: JS-only changes (typewriter animation, AudioContext TTS, Quick Study chip).
194+
// No schema changes needed.
195+
upgrade_plugin_savepoint(true, 2026030700, 'local', 'ai_course_assistant');
196+
}
197+
192198
return true;
193199
}

lang/en/local_ai_course_assistant.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@
408408
$string['chat:starter_ell_practice'] = 'Practice Speaking';
409409
$string['chat:starter_ell_pronunciation'] = 'ELL Pronunciation';
410410
$string['chat:starter_ai_coach'] = 'AI Coach';
411+
$string['chat:starter_quick_study'] = 'Quick Study';
411412
$string['chat:starter_speak'] = 'Speak a starter';
412413
// Legacy keys kept for backwards compatibility.
413414
$string['chat:starter_help_lesson'] = 'Explain This';

styles.css

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -570,8 +570,32 @@
570570
}
571571
}
572572

573+
/* Swipe handle bar — appears above header on mobile, used to drag-to-dismiss */
574+
.aica-swipe-handle {
575+
display: none; /* hidden on desktop */
576+
flex-shrink: 0;
577+
height: 20px;
578+
align-items: center;
579+
justify-content: center;
580+
cursor: grab;
581+
touch-action: pan-y;
582+
-webkit-tap-highlight-color: transparent;
583+
}
584+
.aica-swipe-handle::after {
585+
content: '';
586+
display: block;
587+
width: 36px;
588+
height: 4px;
589+
border-radius: 2px;
590+
background: rgba(0, 0, 0, 0.18);
591+
}
592+
573593
/* Mobile (< 600px) */
574594
@media (max-width: 600px) {
595+
.aica-swipe-handle {
596+
display: flex;
597+
}
598+
575599
.local-ai-course-assistant__drawer {
576600
position: fixed;
577601
left: 8px;
@@ -2477,18 +2501,15 @@
24772501
aica-avatar-pulse 2.5s ease-out infinite;
24782502
}
24792503

2480-
/* Bounce — for preset (non-generated) avatars while speaking */
2481-
@keyframes aica-avatar-bounce {
2482-
0% { transform: translateY(0); }
2483-
35% { transform: translateY(-8px) scale(1.04); }
2484-
55% { transform: translateY(-3px) scale(1.01); }
2485-
70% { transform: translateY(-6px) scale(1.03); }
2486-
100% { transform: translateY(0); }
2504+
/* Glow pulse on the toggle button while TTS is active */
2505+
@keyframes aica-speaking-glow {
2506+
0% { box-shadow: 0 0 0 0px rgba(74, 108, 247, 0.7); }
2507+
50% { box-shadow: 0 0 0 12px rgba(74, 108, 247, 0.15); }
2508+
100% { box-shadow: 0 0 0 0px rgba(74, 108, 247, 0.7); }
24872509
}
24882510

2489-
/* PNG/SVG-file avatars: bounce */
2490-
.aica-avatar-svg--speaking:not(.aica-avatar-svg--has-svg) {
2491-
animation: aica-avatar-bounce 0.65s ease-in-out infinite;
2511+
.local-ai-course-assistant__toggle.aica-speaking {
2512+
animation: aica-speaking-glow 0.7s ease-in-out infinite !important;
24922513
}
24932514

24942515
/* CSS fallback mouth for generated SVG avatars when Web Audio is unavailable */

templates/chat_widget.mustache

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@
7474
aria-label="{{#str}}chat:title, local_ai_course_assistant{{/str}}"
7575
aria-hidden="true">
7676

77+
{{! Swipe handle — mobile only; drag down to close }}
78+
<div class="aica-swipe-handle" aria-hidden="true"></div>
79+
7780
{{! Header }}
7881
<div class="local-ai-course-assistant__header">
7982
<div class="local-ai-course-assistant__header-title">
@@ -260,6 +263,13 @@
260263
</span>
261264
<span>{{#str}}chat:starter_ai_coach, local_ai_course_assistant{{/str}}</span>
262265
</button>
266+
<button class="local-ai-course-assistant__starter" data-starter="quick-study">
267+
<span class="aica-starter-icon" aria-hidden="true">
268+
{{! Timer icon — Quick Study }}
269+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61 1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42A7.73 7.73 0 0 0 12 4a8 8 0 1 0 8 8c0-2.21-.9-4.21-2.03-5.39zm-1.97 9.61A6 6 0 1 1 12 6a6 6 0 0 1 5.06 9.61z"/></svg>
270+
</span>
271+
<span>{{#str}}chat:starter_quick_study, local_ai_course_assistant{{/str}}</span>
272+
</button>
263273
</div>
264274

265275
{{! Typing indicator }}

0 commit comments

Comments
 (0)