From 40efd9b8e3520079bce63d80f11946ad6b480562 Mon Sep 17 00:00:00 2001 From: posimai Date: Sun, 22 Mar 2026 14:40:02 +0900 Subject: [PATCH] feat: Daily-style wave/particles, skip buttons inline, speed to settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Canvas wave with CSS div bars + sparkle animation (Daily style) - Add radial glow behind waveform + particle burst around play button - Play button: Daily style (glow, inset highlight, pulse ring when playing) - Move skip-back/forward next to play button (horizontal row) - Remove separate controls bar - Move speed selector into settings panel - Add VOICEVOX speaker selector in settings panel (ずんだもん/春日部つむぎ/四国めたん) - Article list: show publishedAt time ago next to source - Article list: external-link icon button on right (opens URL in new tab) - stopPropagation on link button so tap doesn't trigger playback jump Co-Authored-By: Claude Sonnet 4.6 --- index.html | 735 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 438 insertions(+), 297 deletions(-) diff --git a/index.html b/index.html index 032fa5a..39cd067 100644 --- a/index.html +++ b/index.html @@ -39,7 +39,15 @@ @@ -338,6 +423,7 @@
+
外観
@@ -356,10 +442,33 @@
+ +
+
再生
+
+
再生速度
+
+ + + + +
+
+
+
音声キャラクター(VOICEVOX)
+
+ + + +
+
+
+ +
Posimai API Key

- 設定すると Synology 経由でずんだもん音声が使えます(VOICEVOX 未設定時はブラウザ音声で再生)。 + 設定すると Synology 経由でずんだもん音声が使えます(未設定時はブラウザ音声)。

@@ -391,7 +500,7 @@
- +
記事を読み込んでいます
@@ -401,34 +510,32 @@
-
- + +
+
+
- + +
+ -
+
+
+ +
-
- - -
- - -
- - - - +
-
@@ -444,87 +551,94 @@ // Posimai Brief — 音声ニュースブリーフィング // ============================================================ -const FEED_API = 'https://posimai-feed.vercel.app/api/feed'; -const API_BASE = 'https://posimai-lab.tail72e846.ts.net/brain/api'; -const TTS_API = API_BASE + '/tts'; -const DAYS_JP = ['日','月','火','水','木','金','土']; +const FEED_API = 'https://posimai-feed.vercel.app/api/feed'; +const API_BASE = 'https://posimai-lab.tail72e846.ts.net/brain/api'; +const TTS_API = API_BASE + '/tts'; +const DAYS_JP = ['日','月','火','水','木','金','土']; // ── 状態 ───────────────────────────────────────────────────── let articles = []; let speechQueue = []; let currentIdx = 0; let isPlaying = false; -let speechRate = 1.0; +let speechRate = parseFloat(localStorage.getItem('posimai-brief-rate') || '1.0'); +let ttsSpeaker = parseInt(localStorage.getItem('posimai-brief-speaker') || '1', 10); let audioCtx = null; let currentSource = null; -let waveRAF = null; -// ── 波形データ ──────────────────────────────────────────────── -const BAR_COUNT = 64; -const BAR_W = 2; -const BAR_GAP = 2; -const barH = new Float32Array(BAR_COUNT).fill(0.04); -const barT = new Float32Array(BAR_COUNT).fill(0.04); -const barSpk = new Float32Array(BAR_COUNT); -let wavePhase = 0; +// ── 波形(CSS divバー、Daily スタイル) ────────────────────── +const WAVE_BAR_COUNT = 36; +const waveBars = []; +let waveRAF = null; +let lastWaveUpdate = 0; -function updateWave(playing) { - wavePhase += playing ? 0.06 : 0; - for (let i = 0; i < BAR_COUNT; i++) { - if (playing) { - const env = 0.15 + 0.5 * Math.abs(Math.sin(wavePhase - i * 0.18)); - if (Math.random() < 0.07) barT[i] = env + Math.random() * (1 - env); - else if (Math.random() < 0.1) barT[i] = 0.04 + Math.random() * 0.12; - if (Math.random() < 0.007) barSpk[i] = 1.0; - } else { - if (Math.random() < 0.04) barT[i] = 0.02 + Math.random() * 0.05; - } - barH[i] += (barT[i] - barH[i]) * (playing ? 0.28 : 0.12); - barSpk[i] *= 0.76; +(function initWave() { + const container = document.getElementById('briefWaveform'); + for (let i = 0; i < WAVE_BAR_COUNT; i++) { + const bar = document.createElement('div'); + bar.className = 'brief-wave-bar'; + container.appendChild(bar); + waveBars.push(bar); } +})(); + +function updateWaveRAF() { + if (!isPlaying) return; + const now = Date.now(); + if (now - lastWaveUpdate >= 150) { + lastWaveUpdate = now; + const time = now * 0.003; + waveBars.forEach((bar, i) => { + const sine = Math.sin(time + i * 0.45) * 22; + const h = Math.max(6, 28 + sine + Math.random() * 16); + bar.style.height = h + 'px'; + if (Math.random() > 0.93) bar.classList.add('sparkle'); + else bar.classList.remove('sparkle'); + }); + } + waveRAF = requestAnimationFrame(updateWaveRAF); } -function drawWave(playing) { - const canvas = document.getElementById('waveCanvas'); - if (!canvas) return; - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - if (canvas.width !== Math.round(rect.width * dpr) || canvas.height !== Math.round(rect.height * dpr)) { - canvas.width = Math.round(rect.width * dpr); - canvas.height = Math.round(rect.height * dpr); - } - const ctx = canvas.getContext('2d'); - const W = canvas.width; - const H = canvas.height; - ctx.clearRect(0, 0, W, H); - - const totalW = BAR_COUNT * (BAR_W + BAR_GAP) - BAR_GAP; - const ox = (W - totalW * dpr) / 2; - - for (let i = 0; i < BAR_COUNT; i++) { - const h = Math.max(3 * dpr, Math.round(H * barH[i])); - const spk = barSpk[i]; - const x = ox + i * (BAR_W + BAR_GAP) * dpr; - const y = (H - h) / 2; - - if (spk > 0.12) { - const t = spk; - const rv = Math.round(110 + (255 - 110) * t); - const gv = Math.round(231 + (255 - 231) * t); - const bv = Math.round(183 + (255 - 183) * t); - ctx.fillStyle = `rgba(${rv},${gv},${bv},${0.5 + t * 0.5})`; - } else { - const alpha = playing ? 0.55 + barH[i] * 0.45 : 0.18; - ctx.fillStyle = `rgba(110,231,183,${alpha})`; - } - ctx.fillRect(x, y, BAR_W * dpr, h); - } +function stopWave() { + cancelAnimationFrame(waveRAF); + waveRAF = null; + waveBars.forEach(bar => { bar.style.height = '8px'; bar.classList.remove('sparkle'); }); } -function waveLoop() { - updateWave(isPlaying); - drawWave(isPlaying); - waveRAF = requestAnimationFrame(waveLoop); +// ── パーティクル ────────────────────────────────────────────── +let particleInterval = null; + +function createParticle() { + if (!isPlaying) return; + const container = document.getElementById('briefParticles'); + const p = document.createElement('div'); + p.className = 'brief-particle'; + const angle = Math.random() * Math.PI * 2; + const dist = 50 + Math.random() * 55; + const size = 2 + Math.random() * 3; + const dur = 0.6 + Math.random() * 0.4; + p.style.cssText = ` + width:${size}px; height:${size}px; + left:50%; top:50%; + --tx:${Math.cos(angle) * dist}px; + --ty:${Math.sin(angle) * dist}px; + animation: brief-particle-sparkle ${dur}s cubic-bezier(0,.5,.5,1) forwards; + `; + container.appendChild(p); + setTimeout(() => p.remove(), 1000); +} + +function startEffects() { + updateWaveRAF(); + particleInterval = setInterval(createParticle, 45); + document.getElementById('briefGlow').style.opacity = '0.8'; +} + +function stopEffects() { + stopWave(); + clearInterval(particleInterval); + particleInterval = null; + document.getElementById('briefGlow').style.opacity = '0.3'; } // ── 音声制御 ────────────────────────────────────────────────── @@ -557,11 +671,10 @@ async function tryVoicevox(text) { if (!apiKey) return false; const ctx = getAudioCtx(); - const res = await fetch(TTS_API, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, - body: JSON.stringify({ text: preprocessText(text), speaker: 1 }), + body: JSON.stringify({ text: preprocessText(text), speaker: ttsSpeaker }), }); if (!res.ok) return false; @@ -571,11 +684,9 @@ async function tryVoicevox(text) { return new Promise(resolve => { currentSource = ctx.createBufferSource(); currentSource.buffer = buf; + currentSource.playbackRate.value = speechRate; currentSource.connect(ctx.destination); - currentSource.onended = () => { - currentSource = null; - resolve(true); - }; + currentSource.onended = () => { currentSource = null; resolve(true); }; currentSource.start(0); updateVoiceBadge(true); }); @@ -584,13 +695,14 @@ async function tryVoicevox(text) { function speakBrowser(text, onEnd) { const utter = new SpeechSynthesisUtterance(preprocessText(text)); const voices = speechSynthesis.getVoices(); - const ja = voices.find(v => v.lang.startsWith('ja') && v.localService) + const ja = voices.find(v => v.lang.startsWith('ja') && !v.localService) + || voices.find(v => v.lang.startsWith('ja') && v.name.match(/Natural|Enhanced|Neural|Premium/)) || voices.find(v => v.lang.startsWith('ja')) || null; if (ja) utter.voice = ja; utter.rate = speechRate; utter.lang = 'ja-JP'; - utter.onend = onEnd; + utter.onend = onEnd; utter.onerror = onEnd; speechSynthesis.speak(utter); updateVoiceBadge(false); @@ -600,16 +712,11 @@ async function speak(text) { if (!isPlaying) return; stopAudio(); - let voicevoxOk = false; - try { - const result = await tryVoicevox(text); - voicevoxOk = result === true; - } catch (e) { - voicevoxOk = false; - } + let vvOk = false; + try { vvOk = (await tryVoicevox(text)) === true; } catch(e) {} if (!isPlaying) return; - if (voicevoxOk) { + if (vvOk) { onSpeakEnd(); } else { speakBrowser(text, () => { if (isPlaying) onSpeakEnd(); }); @@ -626,6 +733,7 @@ function playNext() { if (currentIdx >= speechQueue.length) { isPlaying = false; updatePlayBtn(); + stopEffects(); updateArticleDisplay(articles[articles.length - 1], articles.length - 1); return; } @@ -638,9 +746,10 @@ function playNext() { // ── UI 更新 ─────────────────────────────────────────────────── function updatePlayBtn() { - const btn = document.getElementById('playBtn'); - const icon = isPlaying ? 'pause' : 'play'; - btn.innerHTML = ``; + const btn = document.getElementById('playBtn'); + const inner = document.getElementById('playInner'); + const icon = isPlaying ? 'pause' : 'play'; + inner.innerHTML = ``; btn.classList.toggle('playing', isPlaying); lucide.createIcons(); } @@ -653,18 +762,26 @@ function esc(str) { .replace(/"/g, '"'); } +function formatTimeAgo(dateStr) { + if (!dateStr) return ''; + const diff = Date.now() - new Date(dateStr).getTime(); + const min = Math.floor(diff / 60000); + if (min < 1) return 'たった今'; + if (min < 60) return `${min}分前`; + const h = Math.floor(min / 60); + if (h < 24) return `${h}時間前`; + return `${Math.floor(h / 24)}日前`; +} + function updateArticleDisplay(a, idx) { if (!a) return; - const total = articles.length; - const num = idx + 1; - document.getElementById('briefMeta').textContent = `${num} / ${total}`; - document.getElementById('briefTitle').textContent = a.title || '---'; - document.getElementById('briefSource').textContent = a.source || ''; - document.getElementById('briefProgress').textContent = ''; + document.getElementById('briefMeta').textContent = `${idx + 1} / ${articles.length}`; + document.getElementById('briefTitle').textContent = a.title || '---'; + document.getElementById('briefSource').textContent = a.source || ''; updateListHighlight(idx); if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ - title: a.title || 'Posimai Brief', + title: a.title || 'Posimai Brief', artist: a.source || 'Posimai', album: 'Daily Briefing', }); @@ -675,15 +792,25 @@ function renderList() { const list = document.getElementById('briefList'); list.innerHTML = ''; articles.forEach((a, i) => { - const item = document.createElement('div'); const isActive = i === currentIdx; + const item = document.createElement('div'); item.className = 'brief-list-item' + (isActive ? ' active' : ''); + + const timeStr = formatTimeAgo(a.publishedAt); + const metaParts = [esc(a.source), timeStr].filter(Boolean); + item.innerHTML = ` ${i + 1}
${esc(a.title)}
- ${a.source ? `
${esc(a.source)}
` : ''} -
`; + ${metaParts.length ? `
${metaParts.join('·')}
` : ''} +
+ ${a.url ? ` + + ` : ''} + `; + + // 記事タップ → 音声再生ジャンプ item.addEventListener('click', () => { const wasPlaying = isPlaying; stopAudio(); @@ -693,11 +820,18 @@ function renderList() { if (wasPlaying) { isPlaying = true; updatePlayBtn(); + startEffects(); playNext(); } }); + + // 外部リンクは伝播させない + const link = item.querySelector('.brief-list-link'); + if (link) link.addEventListener('click', e => e.stopPropagation()); + list.appendChild(item); }); + lucide.createIcons(); } function updateListHighlight(idx) { @@ -708,14 +842,14 @@ function updateListHighlight(idx) { const titleEl = el.querySelector('.brief-list-title'); if (numEl) numEl.classList.toggle('active', active); if (titleEl) titleEl.classList.toggle('active', active); - if (active) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + if (active) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }); } function updateVoiceBadge(isVoicevox) { const badge = document.getElementById('voiceBadge'); const label = document.getElementById('voiceBadgeLabel'); - label.textContent = isVoicevox ? 'ずんだもん' : 'ブラウザ音声'; + label.textContent = isVoicevox ? 'VOICEVOX' : 'ブラウザ音声'; badge.classList.toggle('voicevox', isVoicevox); } @@ -726,9 +860,11 @@ document.getElementById('playBtn').addEventListener('click', () => { updatePlayBtn(); if (isPlaying) { if (currentIdx >= speechQueue.length) currentIdx = 0; + startEffects(); playNext(); } else { stopAudio(); + stopEffects(); } }); @@ -742,6 +878,7 @@ function skip(dir) { if (wasPlaying) { isPlaying = true; updatePlayBtn(); + startEffects(); playNext(); } } @@ -751,31 +888,38 @@ document.getElementById('nextBtn').addEventListener('click', () => skip(1)); // MediaSession if ('mediaSession' in navigator) { - navigator.mediaSession.setActionHandler('play', () => { if (!isPlaying) { isPlaying = true; updatePlayBtn(); playNext(); } }); - navigator.mediaSession.setActionHandler('pause', () => { if (isPlaying) { isPlaying = false; updatePlayBtn(); stopAudio(); } }); + navigator.mediaSession.setActionHandler('play', () => { if (!isPlaying) { isPlaying = true; updatePlayBtn(); startEffects(); playNext(); } }); + navigator.mediaSession.setActionHandler('pause', () => { if (isPlaying) { isPlaying = false; updatePlayBtn(); stopAudio(); stopEffects(); } }); navigator.mediaSession.setActionHandler('previoustrack', () => skip(-1)); navigator.mediaSession.setActionHandler('nexttrack', () => skip(1)); } -// ── 速度変更 ───────────────────────────────────────────────── -document.querySelectorAll('.brief-speed-btn').forEach(btn => { - btn.addEventListener('click', () => { - speechRate = parseFloat(btn.dataset.speed); - document.querySelectorAll('.brief-speed-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - localStorage.setItem('posimai-brief-rate', String(speechRate)); - if (isPlaying) { - stopAudio(); - playNext(); - } +// ── 再生速度 ───────────────────────────────────────────────── +function initSpeedBtns() { + document.querySelectorAll('#speedGroup .brief-seg-btn').forEach(btn => { + const v = parseFloat(btn.dataset.speed); + btn.classList.toggle('active', v === speechRate); + btn.addEventListener('click', () => { + speechRate = v; + localStorage.setItem('posimai-brief-rate', String(v)); + document.querySelectorAll('#speedGroup .brief-seg-btn').forEach(b => b.classList.toggle('active', parseFloat(b.dataset.speed) === v)); + if (isPlaying) { stopAudio(); playNext(); } + }); }); -}); +} -const savedRate = parseFloat(localStorage.getItem('posimai-brief-rate') || '1.0'); -speechRate = savedRate; -document.querySelectorAll('.brief-speed-btn').forEach(b => { - b.classList.toggle('active', parseFloat(b.dataset.speed) === savedRate); -}); +// ── 音声キャラクター ────────────────────────────────────────── +function initSpeakerBtns() { + document.querySelectorAll('#speakerGroup .brief-seg-btn').forEach(btn => { + const v = parseInt(btn.dataset.speaker, 10); + btn.classList.toggle('active', v === ttsSpeaker); + btn.addEventListener('click', () => { + ttsSpeaker = v; + localStorage.setItem('posimai-brief-speaker', String(v)); + document.querySelectorAll('#speakerGroup .brief-seg-btn').forEach(b => b.classList.toggle('active', parseInt(b.dataset.speaker, 10) === v)); + }); + }); +} // ── API キー保存 ────────────────────────────────────────────── document.getElementById('apiKeyInput').value = localStorage.getItem('posimai-brief-apikey') || ''; @@ -788,10 +932,7 @@ document.getElementById('apiKeySave').addEventListener('click', () => { // ── 日付表示 ───────────────────────────────────────────────── function updateDate() { const now = new Date(); - const mo = now.getMonth() + 1; - const d = now.getDate(); - const day = DAYS_JP[now.getDay()]; - document.getElementById('briefDate').textContent = `${mo}月${d}日(${day})`; + document.getElementById('briefDate').textContent = `${now.getMonth() + 1}月${now.getDate()}日(${DAYS_JP[now.getDay()]})`; } // ── フィード読み込み ────────────────────────────────────────── @@ -807,7 +948,6 @@ async function loadFeed() { if (!res.ok) throw new Error(`Feed ${res.status}`); const data = await res.json(); const list = (data.articles || []).slice(0, 8); - if (!list.length) throw new Error('no articles'); articles = list; @@ -831,9 +971,9 @@ async function loadFeed() { } } -// ── 再読み込みボタン ────────────────────────────────────────── +// ── 再読み込み ──────────────────────────────────────────────── document.getElementById('refreshBtn').addEventListener('click', () => { - if (isPlaying) { isPlaying = false; stopAudio(); updatePlayBtn(); } + if (isPlaying) { isPlaying = false; stopAudio(); stopEffects(); updatePlayBtn(); } loadFeed(); }); @@ -844,7 +984,8 @@ if ('serviceWorker' in navigator) { // ── 初期化 ─────────────────────────────────────────────────── updateDate(); -waveLoop(); +initSpeedBtns(); +initSpeakerBtns(); loadFeed();