posimai-brief/index.html

858 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ja" data-app-id="posimai-brief">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex, nofollow">
<script>
(function () {
const p = new URLSearchParams(location.search);
const k = p.get('init_key');
if (k) {
localStorage.setItem('posimai-brief-apikey', k);
p.delete('init_key');
const u = location.pathname + (p.toString() ? '?' + p : '');
history.replaceState({}, '', u);
}
const t = localStorage.getItem('posimai-brief-theme') || 'system';
const dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', t);
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="音声ニュースブリーフィング">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0D0D0D" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#F9FAFB" media="(prefers-color-scheme: light)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Brief">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="apple-touch-icon" href="/logo.png">
<title>Posimai Brief</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<style>
/* ── base.css の main スタイルを完全上書き ───────────────────────
base.css: padding:24px 20px / max-width:860px / margin:0 auto
Brief は没入型プレーヤーのため自前でレイアウト管理する */
main {
padding: 0;
max-width: none;
margin: 0;
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* ── トップバー ─────────────────────────────────────────────── */
.brief-top {
display: flex;
align-items: center;
gap: 8px;
padding: max(16px, env(safe-area-inset-top)) 16px 12px;
flex-shrink: 0;
}
.brief-date {
font-size: 13px;
font-weight: 400;
color: var(--text2);
}
.brief-sep { color: var(--border); font-size: 13px; }
.brief-count {
font-size: 13px;
color: var(--text3);
}
.brief-top-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.brief-icon-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.12s, background 0.12s;
}
.brief-icon-btn:active { background: var(--surface); color: var(--text2); }
/* ── プレーヤー本体flex: 1 ──────────────────────────────── */
.brief-player {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
padding: 0 24px;
min-height: 0;
}
/* ── 記事情報 ────────────────────────────────────────────────── */
.brief-article-meta {
font-size: 11px;
font-weight: 500;
color: var(--accent);
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 12px;
text-align: center;
}
.brief-article-title {
font-size: 18px;
font-weight: 500;
color: var(--text);
line-height: 1.45;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.brief-article-source {
font-size: 12px;
color: var(--text3);
text-align: center;
}
/* ── 波形キャンバス ─────────────────────────────────────────── */
.brief-wave-wrap {
width: 100%;
height: 72px;
margin: 28px 0 24px;
display: flex;
align-items: center;
justify-content: center;
}
#waveCanvas {
width: 100%;
height: 100%;
display: block;
}
/* ── 再生ボタン ─────────────────────────────────────────────── */
.brief-play-btn {
width: 72px;
height: 72px;
border-radius: 50%;
border: none;
background: var(--accent);
color: #0D0D0D;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.12s, opacity 0.12s;
box-shadow: 0 0 0 0 rgba(110,231,183,0);
}
.brief-play-btn:active { transform: scale(0.94); }
.brief-play-btn.playing {
animation: playGlow 2.4s ease-in-out infinite;
}
@keyframes playGlow {
0%, 100% { box-shadow: 0 0 0 0 rgba(110,231,183,0.35); }
50% { box-shadow: 0 0 0 12px rgba(110,231,183,0); }
}
/* ── コントロールバー ────────────────────────────────────────── */
.brief-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 16px 24px max(20px, env(safe-area-inset-bottom));
flex-shrink: 0;
width: 100%;
}
.brief-ctrl-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 48px;
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
border-radius: var(--radius);
transition: background 0.12s, color 0.12s;
font-size: 12px;
font-family: inherit;
gap: 4px;
}
.brief-ctrl-btn:active { background: var(--surface); color: var(--text); }
.brief-ctrl-btn.disabled { opacity: 0.3; pointer-events: none; }
.brief-speed-group {
display: flex;
align-items: center;
gap: 2px;
flex: 2;
justify-content: center;
}
.brief-speed-btn {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text3);
font-size: 11px;
font-family: inherit;
cursor: pointer;
transition: all 0.12s;
line-height: 1;
}
.brief-speed-btn.active {
border-color: var(--accent);
color: var(--accent);
background: var(--surface);
}
/* ── 読み込み中・エラー ─────────────────────────────────────── */
.brief-status {
font-size: 13px;
color: var(--text3);
text-align: center;
}
.brief-progress {
font-size: 11px;
color: var(--text3);
text-align: center;
margin-top: 6px;
}
/* ── 設定パネル内の入力 ─────────────────────────────────────── */
.settings-text-input {
width: 100%;
box-sizing: border-box;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
padding: 9px 12px;
outline: none;
font-family: inherit;
margin-top: 6px;
transition: border-color 0.15s;
}
.settings-text-input:focus { border-color: var(--accent); }
.settings-action-btn {
width: 100%;
padding: 10px;
margin-top: 8px;
background: var(--accent);
color: #0D0D0D;
border: none;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.settings-field-label {
font-size: 12px;
color: var(--text2);
line-height: 1.5;
margin-top: 4px;
}
/* ── 記事リスト ─────────────────────────────────────────────── */
.brief-list {
flex-shrink: 0;
max-height: 38dvh;
overflow-y: auto;
border-top: 1px solid var(--border);
scrollbar-width: none;
}
.brief-list::-webkit-scrollbar { display: none; }
.brief-list-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s;
}
.brief-list-item:active { background: var(--surface); }
.brief-list-item.active { background: var(--surface); }
.brief-list-num {
font-size: 11px;
color: var(--text3);
width: 16px;
flex-shrink: 0;
padding-top: 2px;
font-variant-numeric: tabular-nums;
}
.brief-list-num.active { color: var(--accent); }
.brief-list-info { flex: 1; min-width: 0; }
.brief-list-title {
font-size: 13px;
color: var(--text2);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.brief-list-title.active { color: var(--text); font-weight: 500; }
.brief-list-source {
font-size: 11px;
color: var(--text3);
margin-top: 2px;
}
/* ── 音声エンジン表示 ───────────────────────────────────────── */
.voice-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text3);
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 20px;
margin-top: 6px;
}
.voice-badge.voicevox { border-color: var(--accent); color: var(--accent); }
</style>
</head>
<body>
<!-- 設定パネル -->
<aside class="settings-panel" id="settingsPanel" role="complementary">
<div class="settings-panel-header">
<span class="settings-panel-title">設定</span>
<button class="icon-btn" id="settingsCloseBtn" aria-label="設定を閉じる">
<i data-lucide="x" style="width:18px;height:18px;stroke-width:1.75"></i>
</button>
</div>
<div class="settings-panel-body">
<div>
<div class="settings-group-label">外観</div>
<div class="settings-item">
<div class="settings-item-label">テーマ</div>
<div class="theme-selector">
<button class="theme-btn" data-theme-val="dark">
<i data-lucide="moon" style="width:12px;height:12px;stroke-width:1.75"></i>ダーク
</button>
<button class="theme-btn" data-theme-val="light">
<i data-lucide="sun" style="width:12px;height:12px;stroke-width:1.75"></i>ライト
</button>
<button class="theme-btn" data-theme-val="system">
<i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動
</button>
</div>
</div>
</div>
<div style="margin-top:20px">
<div class="settings-group-label">Posimai API Key</div>
<p class="settings-field-label">
設定すると Synology 経由でずんだもん音声が使えますVOICEVOX 未設定時はブラウザ音声で再生)。
</p>
<input class="settings-text-input" id="apiKeyInput" type="password"
placeholder="posimai_api_key" autocomplete="off">
<button class="settings-action-btn" id="apiKeySave">保存</button>
</div>
</div>
</aside>
<div class="overlay" id="overlay" aria-hidden="true"></div>
<main id="main-content">
<!-- トップバー -->
<div class="brief-top">
<span class="brief-date" id="briefDate"></span>
<span class="brief-sep">·</span>
<span class="brief-count" id="briefCount">読み込み中...</span>
<div class="brief-top-right">
<span class="voice-badge" id="voiceBadge">
<i data-lucide="mic" style="width:10px;height:10px;stroke-width:2"></i>
<span id="voiceBadgeLabel">ブラウザ音声</span>
</span>
<button class="brief-icon-btn" id="refreshBtn" aria-label="再読み込み">
<i data-lucide="refresh-cw" style="width:15px;height:15px;stroke-width:2"></i>
</button>
<button class="brief-icon-btn" id="settingsBtn" aria-label="設定">
<i data-lucide="settings" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
</div>
</div>
<!-- プレーヤー -->
<div class="brief-player" id="briefPlayer">
<div class="brief-article-meta" id="briefMeta">Posimai Brief</div>
<div class="brief-article-title" id="briefTitle">記事を読み込んでいます</div>
<div class="brief-article-source" id="briefSource"></div>
<div class="brief-wave-wrap">
<canvas id="waveCanvas"></canvas>
</div>
<button class="brief-play-btn" id="playBtn" aria-label="再生">
<i data-lucide="play" style="width:30px;height:30px;stroke-width:2;fill:#0D0D0D"></i>
</button>
<div class="brief-progress" id="briefProgress"></div>
</div>
<!-- コントロール -->
<div class="brief-controls">
<button class="brief-ctrl-btn" id="prevBtn" aria-label="前の記事">
<i data-lucide="skip-back" style="width:20px;height:20px;stroke-width:2"></i>
</button>
<div class="brief-speed-group">
<button class="brief-speed-btn" data-speed="0.8">0.8x</button>
<button class="brief-speed-btn active" data-speed="1.0">1.0x</button>
<button class="brief-speed-btn" data-speed="1.25">1.25x</button>
<button class="brief-speed-btn" data-speed="1.5">1.5x</button>
</div>
<button class="brief-ctrl-btn" id="nextBtn" aria-label="次の記事">
<i data-lucide="skip-forward" style="width:20px;height:20px;stroke-width:2"></i>
</button>
</div>
<!-- 記事リスト -->
<div class="brief-list" id="briefList"></div>
</main>
<div id="toast" role="status" aria-live="polite"></div>
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
<script defer>
// ============================================================
// 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 = ['日','月','火','水','木','金','土'];
// ── 状態 ─────────────────────────────────────────────────────
let articles = []; // 生記事リスト
let speechQueue = []; // 読み上げテキスト(記事ごと)
let currentIdx = 0; // 現在の発話インデックス
let isPlaying = false;
let speechRate = 1.0;
let audioCtx = null;
let currentSource = null; // VOICEVOX AudioBufferSourceNode
let waveRAF = null; // requestAnimationFrame handle
// ── 波形データ ────────────────────────────────────────────────
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;
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 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 waveLoop() {
updateWave(isPlaying);
drawWave(isPlaying);
waveRAF = requestAnimationFrame(waveLoop);
}
// ── 音声制御 ──────────────────────────────────────────────────
function getAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
function stopAudio() {
// VOICEVOX 停止
if (currentSource) {
currentSource.onended = null;
try { currentSource.stop(); } catch(e) {}
currentSource = null;
}
// ブラウザ TTS 停止
window.speechSynthesis.cancel();
}
function preprocessText(t) {
return (t || '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[「」『』【】〔〕《》]/g, '')
.replace(/([。!?])([^\s])/g, '$1 $2')
.replace(/\s{2,}/g, ' ')
.trim();
}
// VOICEVOX TTS — 成功したら Promise が解決false を返したら fallback
async function tryVoicevox(text) {
const apiKey = localStorage.getItem('posimai-brief-apikey');
if (!apiKey) return false;
const ctx = getAudioCtx(); // await より前に呼ぶChrome autoplay policy
const res = await fetch(TTS_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
body: JSON.stringify({ text: preprocessText(text), speaker: 1 }),
});
if (!res.ok) return false;
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
if (!isPlaying) return true; // 停止中なら再生しない(でも成功扱い)
return new Promise(resolve => {
currentSource = ctx.createBufferSource();
currentSource.buffer = buf;
currentSource.connect(ctx.destination);
currentSource.onended = () => {
currentSource = null;
resolve(true);
};
currentSource.start(0);
updateVoiceBadge(true);
});
}
// ブラウザ TTS
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)
|| voices.find(v => v.lang.startsWith('ja'))
|| null;
if (ja) utter.voice = ja;
utter.rate = speechRate;
utter.lang = 'ja-JP';
utter.onend = onEnd;
utter.onerror = onEnd;
speechSynthesis.speak(utter);
updateVoiceBadge(false);
}
// 1文を話すVOICEVOX 優先 → ブラウザ fallback、重複なし
async function speak(text) {
if (!isPlaying) return;
stopAudio();
let voicevoxOk = false;
try {
const result = await tryVoicevox(text);
voicevoxOk = result === true;
} catch (e) {
voicevoxOk = false;
}
if (!isPlaying) return;
if (voicevoxOk) {
// VOICEVOX onended → playNext() は tryVoicevox 内の Promise.resolve で呼ばれる
// ここで awaitが戻ってきたとき=再生完了なので次へ
onSpeakEnd();
} else {
// ブラウザ TTS fallback
speakBrowser(text, () => { if (isPlaying) onSpeakEnd(); });
}
}
function onSpeakEnd() {
if (!isPlaying) return;
currentIdx++;
playNext();
}
function playNext() {
if (currentIdx >= speechQueue.length) {
isPlaying = false;
updatePlayBtn();
updateArticleDisplay(articles[articles.length - 1], articles.length - 1);
return;
}
updateArticleDisplay(
articles[Math.min(currentIdx, articles.length - 1)],
Math.min(currentIdx, articles.length - 1)
);
speak(speechQueue[currentIdx]);
}
// ── UI 更新 ───────────────────────────────────────────────────
function updatePlayBtn() {
const btn = document.getElementById('playBtn');
const icon = isPlaying ? 'pause' : 'play';
btn.innerHTML = `<i data-lucide="${icon}" style="width:30px;height:30px;stroke-width:2;fill:#0D0D0D"></i>`;
btn.classList.toggle('playing', isPlaying);
lucide.createIcons();
}
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 = '';
updateListHighlight(idx);
// MediaSession
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: a.title || 'Posimai Brief',
artist: a.source || 'Posimai',
album: 'Daily Briefing',
});
}
}
function renderList() {
const list = document.getElementById('briefList');
list.innerHTML = '';
articles.forEach((a, i) => {
const item = document.createElement('div');
item.className = 'brief-list-item' + (i === currentIdx ? ' active' : '');
item.innerHTML = `
<span class="brief-list-num${i === currentIdx ? ' active' : ''}">${i + 1}</span>
<div class="brief-list-info">
<div class="brief-list-title${i === currentIdx ? ' active' : ''}">${a.title || ''}</div>
${a.source ? `<div class="brief-list-source">${a.source}</div>` : ''}
</div>`;
item.addEventListener('click', () => {
const wasPlaying = isPlaying;
stopAudio();
currentIdx = i;
updateArticleDisplay(articles[i], i);
if (wasPlaying) { isPlaying = true; updatePlayBtn(); playNext(); }
});
list.appendChild(item);
});
}
function updateListHighlight(idx) {
document.querySelectorAll('.brief-list-item').forEach((el, i) => {
const active = i === idx;
el.classList.toggle('active', active);
el.querySelector('.brief-list-num').classList.toggle('active', active);
el.querySelector('.brief-list-title').classList.toggle('active', active);
if (active) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}
function updateVoiceBadge(isVoicevox) {
const badge = document.getElementById('voiceBadge');
const label = document.getElementById('voiceBadgeLabel');
label.textContent = isVoicevox ? 'ずんだもん' : 'ブラウザ音声';
badge.classList.toggle('voicevox', isVoicevox);
}
// ── 再生・一時停止 ────────────────────────────────────────────
document.getElementById('playBtn').addEventListener('click', () => {
if (!articles.length) return;
isPlaying = !isPlaying;
updatePlayBtn();
if (isPlaying) {
if (currentIdx >= speechQueue.length) currentIdx = 0;
playNext();
} else {
stopAudio();
}
});
// ── スキップ ─────────────────────────────────────────────────
function skip(dir) {
const wasPlaying = isPlaying;
stopAudio();
currentIdx = Math.max(0, Math.min(speechQueue.length - 1, currentIdx + dir));
updateArticleDisplay(articles[Math.min(currentIdx, articles.length - 1)], Math.min(currentIdx, articles.length - 1));
if (wasPlaying) playNext();
}
document.getElementById('prevBtn').addEventListener('click', () => skip(-1));
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('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();
}
});
});
// 速度を復元
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);
});
// ── API キー保存 ──────────────────────────────────────────────
document.getElementById('apiKeyInput').value = localStorage.getItem('posimai-brief-apikey') || '';
document.getElementById('apiKeySave').addEventListener('click', () => {
const v = document.getElementById('apiKeyInput').value.trim();
localStorage.setItem('posimai-brief-apikey', v);
showToast('保存しました');
});
// ── 日付表示 ─────────────────────────────────────────────────
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}`;
}
// ── フィード読み込み ──────────────────────────────────────────
async function loadFeed() {
document.getElementById('briefTitle').textContent = '記事を読み込んでいます';
document.getElementById('briefSource').textContent = '';
document.getElementById('briefCount').textContent = '...';
try {
const res = await fetch(FEED_API, { cache: 'no-store' });
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;
speechQueue = [];
currentIdx = 0;
list.forEach((a, i) => {
const prefix = i === 0 ? '最初のニュースです。' : '続いて。';
speechQueue.push(`${prefix}${a.source || ''}より。${a.title || ''}`);
});
speechQueue.push('本日のブリーフィングは以上です。');
document.getElementById('briefCount').textContent = `${list.length}`;
updateArticleDisplay(list[0], 0);
renderList();
} catch (e) {
document.getElementById('briefTitle').textContent = '記事の取得に失敗しました';
document.getElementById('briefSource').textContent = 'ネットワークを確認してください';
document.getElementById('briefCount').textContent = 'エラー';
}
}
// ── 再読み込みボタン ──────────────────────────────────────────
document.getElementById('refreshBtn').addEventListener('click', () => {
if (isPlaying) { isPlaying = false; stopAudio(); updatePlayBtn(); }
loadFeed();
});
// ── トースト ─────────────────────────────────────────────────
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2200);
}
// ── SW 登録 ──────────────────────────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// ── 初期化 ───────────────────────────────────────────────────
updateDate();
waveLoop();
loadFeed();
</script>
</body>
</html>