-
-
+
+
-
+
+
-
-
-
-
-
-
-
-
-
-
+
-
@@ -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();