1086 lines
42 KiB
HTML
1086 lines
42 KiB
HTML
<!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';
|
||
|
||
// ── 認証ヘルパー ─────────────────────────────────────────────
|
||
// JWT(posimai_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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.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;
|
||
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>
|