diff --git a/frontend/css/style.css b/frontend/css/style.css
index d431533..208e875 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -1260,4 +1260,72 @@ tr:hover td {
40% {
transform: scale(1);
}
+}
+
+/* ── Audio Player (inline, minimalist) ────────────────────────────── */
+.audio-player {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 12px;
+ padding: 8px 0 2px;
+ border-top: 1px solid var(--glass-border);
+}
+
+.audio-player-btn {
+ width: 28px;
+ height: 28px;
+ min-width: 28px;
+ border: none;
+ border-radius: 50%;
+ background: var(--accent);
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background var(--transition), transform 150ms ease;
+ padding: 0;
+}
+
+.audio-player-btn:hover {
+ background: var(--accent-hover);
+ transform: scale(1.1);
+}
+
+.audio-player-btn:active {
+ transform: scale(0.95);
+}
+
+.audio-player-track {
+ flex: 1;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 2px;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+ transition: height 150ms ease;
+}
+
+.audio-player-track:hover {
+ height: 6px;
+}
+
+.audio-player-fill {
+ height: 100%;
+ width: 0%;
+ background: var(--accent);
+ border-radius: 2px;
+ transition: width 100ms linear;
+}
+
+.audio-player-time {
+ font-size: 0.72rem;
+ font-family: 'Inter', monospace;
+ color: var(--text-muted);
+ min-width: 72px;
+ text-align: right;
+ white-space: nowrap;
+ letter-spacing: 0.02em;
}
\ No newline at end of file
diff --git a/frontend/js/chat.js b/frontend/js/chat.js
index 54ec0cd..c534a4e 100644
--- a/frontend/js/chat.js
+++ b/frontend/js/chat.js
@@ -220,7 +220,7 @@ document.addEventListener('DOMContentLoaded', async () => {
messagesEl.scrollTop = messagesEl.scrollHeight;
if (audioUrl) {
- await voice.playAudio(audioUrl);
+ await voice.playAudio(audioUrl, assistantEl);
}
}
diff --git a/frontend/js/voice.js b/frontend/js/voice.js
index 0c2b0b5..44dea42 100644
--- a/frontend/js/voice.js
+++ b/frontend/js/voice.js
@@ -226,39 +226,130 @@ class VoiceManager {
}
/**
- * Play pre-fetched audio URL with visual feedback.
+ * Play audio with an inline mini-player (progress bar, seek, replay).
+ * @param {string} audioUrl – blob URL from fetchAudio()
+ * @param {HTMLElement} [containerEl] – element to append the player into
+ * @returns {Promise} resolves when first playback ends
*/
- async playAudio(audioUrl) {
+ async playAudio(audioUrl, containerEl) {
if (!audioUrl) return;
const audio = new Audio(audioUrl);
- // Visual feedback
+ // Visual feedback — avatar pulse
const avatarContainer = document.querySelector('.avatar-container');
if (avatarContainer) avatarContainer.classList.add('speaking');
+ // ── Build player DOM ──────────────────────────────────────────
+ const player = document.createElement('div');
+ player.className = 'audio-player';
+
+ const playBtn = document.createElement('button');
+ playBtn.className = 'audio-player-btn playing';
+ playBtn.innerHTML = VoiceManager._pauseIcon();
+ playBtn.title = 'Pause';
+
+ const track = document.createElement('div');
+ track.className = 'audio-player-track';
+ const fill = document.createElement('div');
+ fill.className = 'audio-player-fill';
+ track.appendChild(fill);
+
+ const timeLabel = document.createElement('span');
+ timeLabel.className = 'audio-player-time';
+ timeLabel.textContent = '0:00 / 0:00';
+
+ player.appendChild(playBtn);
+ player.appendChild(track);
+ player.appendChild(timeLabel);
+
+ if (containerEl) {
+ containerEl.appendChild(player);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────
+ function fmt(s) {
+ if (!isFinite(s)) return '0:00';
+ const m = Math.floor(s / 60);
+ const sec = Math.floor(s % 60);
+ return `${m}:${sec.toString().padStart(2, '0')}`;
+ }
+
+ function updateProgress() {
+ if (!audio.duration) return;
+ const pct = (audio.currentTime / audio.duration) * 100;
+ fill.style.width = pct + '%';
+ timeLabel.textContent = `${fmt(audio.currentTime)} / ${fmt(audio.duration)}`;
+ }
+
+ // ── Events ────────────────────────────────────────────────────
+ audio.addEventListener('timeupdate', updateProgress);
+
+ audio.addEventListener('loadedmetadata', () => {
+ timeLabel.textContent = `0:00 / ${fmt(audio.duration)}`;
+ });
+
+ // Seek on track click
+ track.addEventListener('click', (e) => {
+ const rect = track.getBoundingClientRect();
+ const pct = (e.clientX - rect.left) / rect.width;
+ audio.currentTime = pct * audio.duration;
+ updateProgress();
+ });
+
+ // Play/pause toggle
+ playBtn.addEventListener('click', () => {
+ if (audio.paused) {
+ audio.play();
+ playBtn.classList.add('playing');
+ playBtn.innerHTML = VoiceManager._pauseIcon();
+ playBtn.title = 'Pause';
+ if (avatarContainer) avatarContainer.classList.add('speaking');
+ } else {
+ audio.pause();
+ playBtn.classList.remove('playing');
+ playBtn.innerHTML = VoiceManager._playIcon();
+ playBtn.title = 'Play';
+ if (avatarContainer) avatarContainer.classList.remove('speaking');
+ }
+ });
+
+ // ── Playback ──────────────────────────────────────────────────
try {
await audio.play();
return new Promise(resolve => {
audio.onended = () => {
if (avatarContainer) avatarContainer.classList.remove('speaking');
- URL.revokeObjectURL(audioUrl);
+ playBtn.classList.remove('playing');
+ playBtn.innerHTML = VoiceManager._playIcon();
+ playBtn.title = 'Replay';
+ fill.style.width = '100%';
+ // Reset to beginning for replay
+ audio.currentTime = 0;
resolve();
};
- // Handle errors during playback (e.g. format issues)
audio.onerror = () => {
if (avatarContainer) avatarContainer.classList.remove('speaking');
- URL.revokeObjectURL(audioUrl);
resolve();
- }
+ };
});
} catch (e) {
- console.error("Playback failed", e);
+ console.error('Playback failed', e);
if (avatarContainer) avatarContainer.classList.remove('speaking');
- URL.revokeObjectURL(audioUrl);
+ playBtn.classList.remove('playing');
+ playBtn.innerHTML = VoiceManager._playIcon();
}
}
+ // ── SVG icons (inline, no external deps) ──────────────────────────
+ static _playIcon() {
+ return ``;
+ }
+
+ static _pauseIcon() {
+ return ``;
+ }
+
/**
* Legacy method for backward compatibility if needed,
* or for simple direct speech.