posimai-brief/index.html

1086 lines
42 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 rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="preconnect" href="https://posimai-ui.vercel.app">
<link rel="preconnect" href="https://api.soar-enrich.com">
<link href="https://fonts.googleapis.com/css2?family=Geist: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" integrity="sha384-tTkFttkBclaU1cloKwOi9xk3pbao3VZxTjLNBt8iFABWDBQibbAbWpVmO28zMuxq" crossorigin="anonymous"></script>
<style>
/* ── アクセントグロー変数 ─────────────────────────────── */
:root {
--accent-glow: rgba(110, 231, 183, 0.3);
}
[data-theme="light"] {
--accent-glow: rgba(5, 150, 105, 0.2);
}
/* ── 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: 15px; font-weight: 400; color: var(--text2); }
.brief-sep { color: var(--border); font-size: 15px; }
.brief-count { font-size: 15px; color: var(--text3); }
.brief-top-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
}
.brief-icon-btn {
width: 40px;
height: 40px;
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);
}
.brief-article-meta {
font-size: 12px;
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;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 6px;
}
.brief-article-source { font-size: 14px; color: var(--text3); }
/* ── プレーヤー(波形 + 再生コントロールflex: 1 ─────── */
.brief-player {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 20px;
min-height: 120px;
overflow: hidden;
position: relative;
}
/* ── 波形エリア ────────────────────────────────────────── */
.brief-visual {
width: 100%;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin-bottom: 14px;
}
.brief-visual-glow {
position: absolute;
width: 240px;
height: 70px;
background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%);
filter: blur(20px);
z-index: 0;
opacity: 0.6;
pointer-events: none;
}
.brief-waveform {
display: flex;
align-items: center;
gap: 3px;
height: 56px;
z-index: 1;
}
.brief-wave-bar {
width: 3px;
height: 8px;
background: var(--accent);
border-radius: 4px;
box-shadow: 0 0 8px var(--accent-glow);
transition: height 0.15s ease;
flex-shrink: 0;
}
@keyframes brief-wave-sparkle {
0%, 100% {
filter: brightness(1) drop-shadow(0 0 5px var(--accent-glow));
background: var(--accent);
}
50% {
filter: brightness(2.5) drop-shadow(0 0 20px #fff);
background: #fff;
}
}
.brief-wave-bar.sparkle {
animation: brief-wave-sparkle 0.4s infinite alternate cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── 再生コントロール行skip · play · skip ────────── */
.brief-controls-row {
display: flex;
align-items: center;
gap: 32px;
position: relative;
}
.brief-skip-btn {
width: 44px;
height: 44px;
border: none;
background: transparent;
color: var(--text2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
transition: color 0.12s, transform 0.1s;
}
.brief-skip-btn:active { transform: scale(0.9); color: var(--text); }
/* Daily スタイル再生ボタン */
.brief-play-main {
width: 80px;
height: 80px;
background: var(--accent);
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 0 40px var(--accent-glow), inset 0 0 15px rgba(255,255,255,0.3);
transition: transform 0.2s cubic-bezier(.4, 0, .2, 1);
position: relative;
}
.brief-play-main::after {
content: '';
position: absolute;
inset: -12px;
border: 1px solid var(--accent);
border-radius: 50%;
opacity: 0;
}
.brief-play-main.playing::after {
opacity: 0.2;
animation: brief-pulse-ring 2s linear infinite;
}
@keyframes brief-pulse-ring {
0% { transform: scale(1); opacity: 0.4; }
100% { transform: scale(1.4); opacity: 0; }
}
.brief-play-main:active { transform: scale(0.92); }
.brief-play-inner {
width: 66px;
height: 66px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.05);
display: flex;
align-items: center;
justify-content: center;
color: #000;
}
/* パーティクル */
.brief-particles {
position: absolute;
width: 160px;
height: 160px;
pointer-events: none;
z-index: -1;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.brief-particle {
position: absolute;
background: #fff;
border-radius: 50%;
opacity: 0;
box-shadow: 0 0 10px var(--accent);
filter: blur(0.5px);
}
@keyframes brief-particle-sparkle {
0% { transform: translate(0, 0) scale(0); opacity: 0; }
20% { opacity: 1; }
100% { transform: translate(var(--tx), var(--ty)) scale(1.5); opacity: 0; }
}
/* ── 設定パネル内の入力・スピード ────────────────────── */
.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;
}
/* speed/speaker ボタングループ */
.brief-seg-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 8px;
}
.brief-seg-btn {
padding: 7px 13px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text3);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all 0.12s;
line-height: 1;
}
.brief-seg-btn.active {
border-color: var(--accent);
color: var(--accent);
background: var(--surface);
}
/* ── 音声エンジン表示 ──────────────────────────────── */
.voice-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text3);
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 20px;
}
.voice-badge.voicevox { border-color: var(--accent); color: var(--accent); }
/* ── 記事リスト ─────────────────────────────────────── */
.brief-list {
flex-shrink: 0;
max-height: 40dvh;
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: 10px;
padding: 11px 16px 11px 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: 13px;
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: 15px;
color: var(--text2);
line-height: 1.4;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.brief-list-title.active { color: var(--text); font-weight: 500; }
.brief-list-meta {
font-size: 12px;
color: var(--text3);
margin-top: 3px;
display: flex;
align-items: center;
gap: 4px;
}
.brief-list-link {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text3);
transition: color 0.12s, background 0.12s;
text-decoration: none;
align-self: center;
}
.brief-list-link:active { background: var(--border); color: var(--text2); }
</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">再生</div>
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:0">
<div class="settings-item-label">再生速度</div>
<div class="brief-seg-group" id="speedGroup">
<button class="brief-seg-btn" data-speed="0.8">0.8x</button>
<button class="brief-seg-btn active" data-speed="1.0">1.0x</button>
<button class="brief-seg-btn" data-speed="1.25">1.25x</button>
<button class="brief-seg-btn" data-speed="1.5">1.5x</button>
</div>
</div>
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:0;margin-top:12px">
<div class="settings-item-label">音声キャラクターVOICEVOX</div>
<div class="brief-seg-group" id="speakerGroup">
<button class="brief-seg-btn active" data-speaker="1">ずんだもん</button>
<button class="brief-seg-btn" data-speaker="8">春日部つむぎ</button>
<button class="brief-seg-btn" data-speaker="2">四国めたん</button>
<button class="brief-seg-btn" data-speaker="13">青山龍星</button>
<button class="brief-seg-btn" data-speaker="84">青山(しっとり)</button>
<button class="brief-seg-btn" data-speaker="11">玄野武宏</button>
</div>
</div>
</div>
<!-- 認証 / VOICEVOX -->
<div style="margin-top:20px">
<div class="settings-group-label">アカウント</div>
<p class="settings-field-label" id="authStatusLabel">
ログインするとずんだもん音声・カスタムRSSが使えます。
</p>
<div id="authLoggedIn" style="display:none">
<div id="authUserLine" style="font-size:13px;color:var(--accent);margin-bottom:8px"></div>
<button class="settings-action-btn" id="authLogoutBtn" style="background:transparent;border:1px solid var(--border);color:var(--text2)">ログアウト</button>
</div>
<div id="authLoggedOut">
<a class="settings-action-btn" id="authLoginBtn"
style="display:inline-flex;align-items:center;gap:6px;text-decoration:none"
href="#">ログインする</a>
</div>
</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:12px;height:12px;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:18px;height:18px;stroke-width:2"></i>
</button>
<button class="brief-icon-btn" id="settingsBtn" aria-label="設定">
<i data-lucide="settings" style="width:18px;height:18px;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-visual">
<div class="brief-visual-glow" id="briefGlow"></div>
<div class="brief-waveform" id="briefWaveform"></div>
</div>
<!-- skip-back · play · skip-forward -->
<div class="brief-controls-row">
<button class="brief-skip-btn" id="prevBtn" aria-label="前の記事">
<i data-lucide="skip-back" style="width:24px;height:24px;stroke-width:2"></i>
</button>
<div style="position:relative">
<div class="brief-particles" id="briefParticles"></div>
<button class="brief-play-main" id="playBtn" aria-label="再生">
<div class="brief-play-inner" id="playInner">
<i data-lucide="play" style="fill:#000;width:32px;height:32px;stroke-width:2"></i>
</div>
</button>
</div>
<button class="brief-skip-btn" id="nextBtn" aria-label="次の記事">
<i data-lucide="skip-forward" style="width:24px;height:24px;stroke-width:2"></i>
</button>
</div>
</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://api.soar-enrich.com/brain/api';
const TTS_API = API_BASE + '/tts';
// ── 認証ヘルパー ─────────────────────────────────────────────
// JWTposimai_tokenを優先、なければ旧来の pk_ キーにフォールバック
function getAuthToken() {
return localStorage.getItem('posimai_token') || localStorage.getItem('posimai-brief-apikey') || '';
}
function isLoggedIn() { return !!localStorage.getItem('posimai_token'); }
function getLoginUrl() {
return 'https://posimai.soar-enrich.com/login?redirect=' + encodeURIComponent(location.href);
}
const DAYS_JP = ['日','月','火','水','木','金','土'];
// ── 状態 ─────────────────────────────────────────────────────
let articles = [];
let speechQueue = [];
let currentIdx = 0;
let isPlaying = false;
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;
// ── 波形CSS divバー、Daily スタイル) ──────────────────────
const WAVE_BAR_COUNT = 36;
const waveBars = [];
let waveRAF = null;
let lastWaveUpdate = 0;
(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 stopWave() {
cancelAnimationFrame(waveRAF);
waveRAF = null;
waveBars.forEach(bar => { bar.style.height = '8px'; bar.classList.remove('sparkle'); });
}
// ── パーティクル ──────────────────────────────────────────────
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';
}
// ── 音声制御 ──────────────────────────────────────────────────
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 token = getAuthToken();
if (!token) return false;
const ctx = getAudioCtx();
for (let attempt = 0; attempt < 4; attempt++) {
if (attempt > 0) await new Promise(r => setTimeout(r, 2000 * attempt));
const res = await fetch(TTS_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ text: preprocessText(text), speaker: ttsSpeaker }),
});
if (res.status === 503) {
const body = await res.json().catch(() => ({}));
if (body.error === 'TTS_BUSY' && attempt < 3) continue; // 自動リトライ
const msg = body.error === 'TTS_BUSY' ? 'サーバーが混雑しています' : 'VOICEVOXが起動していません';
if (currentIdx === 0 && typeof showToast === 'function') showToast('VOICEVOX: ' + msg);
return false;
}
if (!res.ok) {
const msg = res.status === 401 ? 'APIキーが無効です' : `サーバーエラー (${res.status})`;
if (currentIdx === 0 && typeof showToast === 'function') showToast('VOICEVOX: ' + msg);
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.playbackRate.value = speechRate;
currentSource.connect(ctx.destination);
currentSource.onended = () => { currentSource = null; resolve(true); };
currentSource.start(0);
updateVoiceBadge(true);
});
}
return false;
}
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') && 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.onerror = onEnd;
speechSynthesis.speak(utter);
updateVoiceBadge(false);
}
async function speak(text) {
if (!isPlaying) return;
stopAudio();
let vvOk = false;
try { vvOk = (await tryVoicevox(text)) === true; } catch(e) {
if (currentIdx === 0 && typeof showToast === 'function') showToast('VOICEVOX エラー: ' + (e.message || e));
}
if (!isPlaying) return;
if (vvOk) {
onSpeakEnd();
} else {
speakBrowser(text, () => { if (isPlaying) onSpeakEnd(); });
}
}
function onSpeakEnd() {
if (!isPlaying) return;
currentIdx++;
playNext();
}
function playNext() {
if (currentIdx >= speechQueue.length) {
isPlaying = false;
updatePlayBtn();
stopEffects();
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 inner = document.getElementById('playInner');
const icon = isPlaying ? 'pause' : 'play';
inner.innerHTML = `<i data-lucide="${icon}" style="fill:#000;width:32px;height:32px;stroke-width:2"></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 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;
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',
artist: a.source || 'Posimai',
album: 'Daily Briefing',
});
}
}
function renderList() {
const list = document.getElementById('briefList');
list.innerHTML = '';
articles.forEach((a, i) => {
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 = `
<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>
${metaParts.length ? `<div class="brief-list-meta">${metaParts.join('<span style="opacity:.4">·</span>')}</div>` : ''}
</div>
${a.url ? `<a class="brief-list-link" href="${esc(a.url)}" target="_blank" rel="noopener noreferrer" aria-label="記事を開く">
<i data-lucide="external-link" style="width:16px;height:16px;stroke-width:1.75"></i>
</a>` : ''}
`;
// 記事タップ → 音声再生ジャンプ
item.addEventListener('click', () => {
const wasPlaying = isPlaying;
stopAudio();
isPlaying = false;
currentIdx = i;
updateArticleDisplay(articles[i], i);
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) {
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 ? 'VOICEVOX' : 'ブラウザ音声';
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;
startEffects();
playNext();
} else {
stopAudio();
stopEffects();
}
});
// ── スキップ ─────────────────────────────────────────────────
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();
startEffects();
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(); 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));
}
// ── 再生速度 ─────────────────────────────────────────────────
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(); }
});
});
}
// ── 音声キャラクター ──────────────────────────────────────────
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));
});
});
}
// ── 認証 UI 初期化 ───────────────────────────────────────────
function updateAuthUI() {
const loggedIn = isLoggedIn();
document.getElementById('authLoggedIn').style.display = loggedIn ? '' : 'none';
document.getElementById('authLoggedOut').style.display = loggedIn ? 'none' : '';
if (loggedIn) {
try {
const payload = JSON.parse(atob(localStorage.getItem('posimai_token').split('.')[1]));
document.getElementById('authUserLine').textContent = 'ログイン中: ' + (payload.userId || '');
} catch (_) {}
}
document.getElementById('authLoginBtn').href = getLoginUrl();
// 音声バッジも更新
updateVoiceBadge(!isLoggedIn() && !localStorage.getItem('posimai-brief-apikey'));
}
document.getElementById('authLogoutBtn').addEventListener('click', () => {
localStorage.removeItem('posimai_token');
updateAuthUI();
if (typeof showToast === 'function') showToast('ログアウトしました');
});
updateAuthUI();
// ── 日付表示 ─────────────────────────────────────────────────
function updateDate() {
const now = new Date();
document.getElementById('briefDate').textContent = `${now.getMonth() + 1}${now.getDate()}日(${DAYS_JP[now.getDay()]}`;
}
// ── フィード読み込み ──────────────────────────────────────────
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 token = getAuthToken();
let res;
if (token) {
let customFeeds = [];
try {
const mediaRes = await fetch(API_BASE + '/feed/media', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (mediaRes.ok) {
const medias = await mediaRes.json();
customFeeds = medias
.filter(m => m.is_active !== false)
.map(m => ({
id: `db-${m.id}`,
name: m.name,
url: m.feed_url,
siteUrl: m.site_url,
category: m.category || 'tech',
icon: 'rss',
dbId: m.id,
}));
}
} catch (_) { /* カスタムソース取得失敗 → デフォルトのみで続行 */ }
try {
res = await fetch(FEED_API, {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ customFeeds }),
});
} catch (_) {
res = await fetch(FEED_API, { cache: 'no-store' });
}
if (res.status === 401) {
res = await fetch(FEED_API, { cache: 'no-store' });
}
} else {
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(); stopEffects(); updatePlayBtn(); }
loadFeed();
});
// ── SW 登録 ──────────────────────────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// ── 初期化 ───────────────────────────────────────────────────
updateDate();
initSpeedBtns();
initSpeakerBtns();
loadFeed();
</script>
</body>
</html>