From 99c20022dee3e99463e0e9748e0e22b1891c84bf Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Wed, 18 Feb 2026 13:03:40 +0100 Subject: [PATCH] added play, see, replay bar for audio --- frontend/css/style.css | 68 +++++++++++++++++++++++++ frontend/js/chat.js | 2 +- frontend/js/voice.js | 109 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 169 insertions(+), 10 deletions(-) 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.