posimai-brief/index.html

852 lines
32 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 を没入型レイアウト用に上書き ── */
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(14px, env(safe-area-inset-top)) 16px 10px;
flex-shrink: 0;
}
.brief-date {
font-size: 14px;
font-weight: 400;
color: var(--text2);
}
.brief-sep { color: var(--border); font-size: 14px; }
.brief-count { font-size: 14px; color: var(--text3); }
.brief-top-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
}
.brief-icon-btn {
width: 34px;
height: 34px;
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); }
/* ── 現在再生中の記事情報(上部に固定配置) ──────── */
.brief-now-playing {
flex-shrink: 0;
padding: 14px 20px 16px;
border-bottom: 1px solid var(--border);
min-height: 80px;
}
.brief-article-meta {
font-size: 11px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.brief-article-title {
font-size: 20px;
font-weight: 500;
color: var(--text);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 6px;
}
.brief-article-source {
font-size: 13px;
color: var(--text3);
}
/* ── プレーヤー(波形 + 再生ボタンのみflex: 1 ─── */
.brief-player {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
padding: 0 24px;
min-height: 100px;
overflow: hidden;
}
/* ── 波形キャンバス ─────────────────────────────── */
.brief-wave-wrap {
width: 100%;
height: 56px;
margin: 0 0 22px;
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 14px rgba(110,231,183,0); }
}
.brief-progress {
font-size: 12px;
color: var(--text3);
text-align: center;
margin-top: 14px;
min-height: 16px;
}
/* ── コントロールバー ────────────────────────────── */
.brief-controls {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 24px 10px;
flex-shrink: 0;
width: 100%;
border-top: 1px solid var(--border);
}
.brief-ctrl-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 44px;
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
border-radius: var(--radius);
transition: background 0.12s, color 0.12s;
font-family: inherit;
}
.brief-ctrl-btn:active { background: var(--surface); color: var(--text); }
.brief-speed-group {
display: flex;
align-items: center;
gap: 4px;
flex: 2;
justify-content: center;
}
.brief-speed-btn {
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text3);
font-size: 12px;
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-list {
flex-shrink: 0;
max-height: 42dvh;
overflow-y: auto;
scrollbar-width: none;
border-top: 1px solid var(--border);
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
.brief-list::-webkit-scrollbar { display: none; }
.brief-list-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 20px;
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: 12px;
color: var(--text3);
width: 18px;
text-align: right;
flex-shrink: 0;
padding-top: 2px;
font-variant-numeric: tabular-nums;
}
.brief-list-num.active { color: var(--accent); font-weight: 600; }
.brief-list-info { flex: 1; min-width: 0; }
.brief-list-title {
font-size: 14px;
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: 12px;
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;
}
.voice-badge.voicevox { border-color: var(--accent); color: var(--accent); }
/* ── 設定パネル内の入力 ─────────────────────────── */
.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;
}
</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-now-playing">
<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>
<!-- プレーヤー(波形 + 再生ボタン) -->
<div class="brief-player" id="briefPlayer">
<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>
// ============================================================
// 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;
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;
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() {
if (currentSource) {
currentSource.onended = null;
try { currentSource.stop(); } catch(e) {}
currentSource = null;
}
window.speechSynthesis.cancel();
}
function preprocessText(t) {
return (t || '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[「」『』【】〔〕《》]/g, '')
.replace(/([。!?])([^\s])/g, '$1 $2')
.replace(/\s{2,}/g, ' ')
.trim();
}
async function tryVoicevox(text) {
const apiKey = localStorage.getItem('posimai-brief-apikey');
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 }),
});
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);
});
}
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);
}
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) {
onSpeakEnd();
} else {
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 esc(str) {
return (str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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);
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');
const isActive = i === currentIdx;
item.className = 'brief-list-item' + (isActive ? ' active' : '');
item.innerHTML = `
<span class="brief-list-num${isActive ? ' active' : ''}">${i + 1}</span>
<div class="brief-list-info">
<div class="brief-list-title${isActive ? ' active' : ''}">${esc(a.title)}</div>
${a.source ? `<div class="brief-list-source">${esc(a.source)}</div>` : ''}
</div>`;
item.addEventListener('click', () => {
const wasPlaying = isPlaying;
stopAudio();
isPlaying = false;
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);
const numEl = el.querySelector('.brief-list-num');
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' });
});
}
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();
isPlaying = false;
currentIdx = Math.max(0, Math.min(articles.length - 1, currentIdx + dir));
updateArticleDisplay(articles[currentIdx], currentIdx);
if (wasPlaying) {
isPlaying = true;
updatePlayBtn();
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);
if (typeof showToast === 'function') 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('briefMeta').textContent = 'Posimai Brief';
document.getElementById('briefCount').textContent = '...';
document.getElementById('briefList').innerHTML = '';
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('briefMeta').textContent = 'ERROR';
document.getElementById('briefCount').textContent = 'エラー';
}
}
// ── 再読み込みボタン ──────────────────────────────────────────
document.getElementById('refreshBtn').addEventListener('click', () => {
if (isPlaying) { isPlaying = false; stopAudio(); updatePlayBtn(); }
loadFeed();
});
// ── SW 登録 ──────────────────────────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// ── 初期化 ───────────────────────────────────────────────────
updateDate();
waveLoop();
loadFeed();
</script>
</body>
</html>